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/account-password-columns-internal.md b/.changeset/account-password-columns-internal.md deleted file mode 100644 index 77ea33a8bc..0000000000 --- a/.changeset/account-password-columns-internal.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -"@objectstack/platform-objects": patch -"@objectstack/plugin-auth": patch ---- - -fix(security): `sys_account.password` and `previous_password_hashes` stop serializing on the data API — `internal: true`, with the raw-engine readers converted to the privileged accessor (#8676) - - - -`sys_account.password` (the credential hash) and `previous_password_hashes` (the -ADR-0069 D1 reuse-prevention ring) serialized on `/api/v1/data/sys_account`, -which declares `apiEnabled: true, apiMethods: ['get','list']` — to an **admin -for every user's row**, and to a **member for their own** (the -`sys_account_self` RLS policy grants `select` on `user_id == current_user.id`). - -These are one-way hashes, not reversible outbound credentials — which is why -#7987 correctly refused to bundle them with the OAuth tokens. But a served -password hash is an offline-cracking target, and `previous_password_hashes` -multiplies it by the history ring while its own declaration says it is *never -exposed in UI*. This is the disposition #7728 already reached for -`sys_api_key.key`, which was **also** a stored hash and was still ruled unfit to -serialize through the API face. - -Neither credential collector could reach them: `collectMaskedReadFields` keys on -the field **TYPE** (`secret` / `password`) *and* exempts objects declaring -`managedBy: 'better-auth'`, which this object is — while these columns are -`text` / `textarea`. Two independent barriers, both missing. - -**The fix is two declarations plus two recovery seams**, and the second seam is -the part a bare flag would have missed: - -- both columns are declared `internal: true` — the opt-in, type-independent flag - from #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's adapter readers** are recovered by the existing per-object - readback table, widened with `password`: the sign-in verifier compares against - the hash on the row `internalAdapter.findCredentialAccount(userId)` returns, - so the strip alone would break password sign-in for every user. -- **plugin-auth's own RAW-engine readers** are recovered by a new seam in the - same module, `recoverInternalFieldsForSystemRead`. This is the half that makes - the flag safe: the readback table is imported by exactly one file - (better-auth's storage adapter), so it cannot reach a caller that reads the - engine directly — and the engine's strip has **no `isSystem` carve-out** by - #7728's design. Measured against a real ObjectQL engine: the reuse ring's - `findOne` returns `{"id":"a1"}` for a query that names both columns in an - explicit projection under `context: { isSystem: true }`. - - Left unrecovered, `assertPasswordNotReused` would become a **silent no-op** — - its comparison list empties, the loop never runs, `PASSWORD_REUSE` is never - thrown, and its own `catch { return undefined }` means nothing announces it. - The ADR-0069 D1 control would report success while accepting every reused - password. Its unit tests would have stayed green throughout, because they use - fake engines that never apply the strip. - -**No ADR-0100 guard change, and none was needed.** `Engine.resolveInternalField` -has exactly one predicate — `internal === true` — so flagging the columns makes -them legitimately dereferenceable through the privileged accessor. The ADR-0100 -sentence in its refusal message is prose explaining why a *non-flagged* field has -other channels, not a second predicate; the guard stays exactly as selective as -it was, and a non-flagged column on the same object is still refused with -`INVALID_FIELD` / 400. - -Regression proof drives both directions on a real booted stack: both columns are -absent for both personas — including a caller who spells them out in `?select=` — -while the values remain on disk and reachable through the privileged accessor, -password sign-in still works, and the reuse ring still grows across a password -change on every transport lane. 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/authz-matrix-scope-narrowing.md b/.changeset/authz-matrix-scope-narrowing.md deleted file mode 100644 index 4f248d9ea2..0000000000 --- a/.changeset/authz-matrix-scope-narrowing.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/dogfood": patch ---- - -docs(qa): narrow the ADR-0056 D10 authz conformance matrix's advertised completeness claim to what its ratchet actually checks (#8711) - -The matrix header and its companion test's header previously read as though -a new declared-but-unenforced authorization primitive would "break CI." It -would not, for most of the ledger: the completeness `discover()` ratchets is -over a **curated table of HTTP/transport entry points** (15 probes over 11 -named source files), not over primitives. A primitive enforced by a predicate -inside an existing resolver — the `sys_permission_set.active` / -`sys_position.active` rows added in #8812 are the normal case, not an -exception — adds no entry point, so it can be neither UNCLASSIFIED nor STALE. - -Both headers now say so explicitly, carrying the measured numbers so the -narrowed claim is load-bearing rather than vague: 43 of the matrix's 50 rows -carry no `covers` key at all, 37 of the 43 `enforced` rows are exactly that -in-resolver shape, and — preserved, because it is real — 5 of the file's 9 -`covers` keys are gate-pins that vanish (and fail CI) when the guard call -they name is deleted. Prose and comments only; nothing about the ratchet's -checking behaviour, the `discover()` table, or any row changes. Maintainer -ruling on #8711 (Option A): narrow the claim, do not build a -primitive-discovery ratchet (measured unachievable in general form). 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/cascade-delete-probe-failure-surfaces.md b/.changeset/cascade-delete-probe-failure-surfaces.md deleted file mode 100644 index e9c24d7024..0000000000 --- a/.changeset/cascade-delete-probe-failure-surfaces.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): a cascade-delete dependents probe that FAILS no longer skips the referential guard — only an unprovisioned child table is read as "no dependents" (#8895) - -`ObjectQL.cascadeDeleteRelations()` probes each child relation -(`find(child, { where: { fk: id } })`) to decide what the parent's delete must -do. That probe **is** the referential-integrity guard, and it sat behind a bare -`catch { continue; }` — so **any** failure of it (a connection drop, a timeout, -a permission denial, a query error, a missing column) was indistinguishable -from "this child has no rows": - -- a `deleteBehavior: 'restrict'` relation never refused the delete, so a delete - the integrity rules say must be **refused was allowed through**; -- `set_null` / `cascade` never ran, so child rows that should have been nulled - or removed were **left orphaned**, pointing at a parent that no longer exists; -- nothing was logged and nothing was returned, so the caller was told the - delete **succeeded**. - -That is fail-OPEN on an integrity guard: the read never happened and the answer -"there are none" was invented for it (ADR-0110 D3 — "the probe found nothing" -and "the probe could not run" are different facts, and here they have opposite -meanings). - -The `catch` is not removed; it is **discriminated by error type**, through the -same shared `isMissingTableError` predicate (`@objectstack/metadata/errors`) -that `seedAutonumber` and `resolveFileReferences` already use: - -- **benign, unchanged** — the child object is registered but its **table** was - never provisioned (schema sync not run yet). It cannot hold a row referencing - anything, so zero dependents is the truth and the relation is skipped exactly - as before. -- **everything else now surfaces** — the delete fails with the probe's own - error, envelope intact, and nothing is written. A guard that could not be - **evaluated** must not silently pass. - -No new error code, no new response field: the caller receives the failure the -probe itself raised. The only behavioural change is that a delete which used to -report success over an unreadable child relation now reports the failure that -made the relation unreadable. 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 1d6b19dc99..0000000000 --- a/.changeset/cbp-missing-master-insert-validation-envelope.md +++ /dev/null @@ -1,97 +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 those -three declarations, and no further. But confined is not unreachable: #8772 -*proposes* a publish-time lint that would refuse them, and that issue is open -and unruled, so nothing refuses them at publish today. A `master_detail` with -no `required` draws only a non-blocking `warning`; `required` + `readonly` and -`required` + `system` draw nothing at all. An app can therefore newly declare -any of the three, publish cleanly, and still see the old -`422 MISSING_REQUIRED_FIELD` with no `fields[]` — so treat these shapes as a -live surface to avoid authoring into, not as a legacy tail that is already -closing. 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/dashboard-modal-target-page-only-lint.md b/.changeset/dashboard-modal-target-page-only-lint.md deleted file mode 100644 index ea1cea9886..0000000000 --- a/.changeset/dashboard-modal-target-page-only-lint.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -'@objectstack/lint': minor -'@objectstack/spec': minor ---- - -`os validate`: a dashboard header `modal` action's target resolves against declared PAGES, only (#9013) - -`validateDashboardActionRefs` resolved an `actionType: 'modal'` header button's -`actionUrl` the way objectui's `DashboardView` used to dispatch it: a defined -action name, a bare object name, or the `_` prefix form -(`create_`/`new_`/`add_`/`edit_`/`update_` + a defined object) all passed, and a -target naming a declared page ERRORED unless it collided with one of those. - -That mirror is gone. Maintainer ruling objectstack#6739-A (2026-08-09): a -`type: 'modal'` string target names a PAGE, only — the spec TSDoc, the published -docs and `defineStack`'s cross-reference walk already said so, and objectui#4764 -/ objectui#4782 retired the renderer's object fallback and `DashboardView`'s -second copy of the prefix convention (enumerated across both repos' corpora: -zero producers). After that, `os validate` blessed exactly the buttons the -runtime refuses — the false affordance the rule exists to eliminate — while -refusing the one shape the runtime serves. - -**BREAKING** accept-set change on the `os validate` gating tier (landing after -the v17.0.0 cut; the lockstep launch-window convention ships it as `minor`): - -- A `modal` header target naming a defined action, a bare object, or a - `_` form now **fails** validation. Those buttons already - dispatch to a named refusal at runtime. -- A `modal` header target naming a declared page now **passes** — it was - wrongly refused before. - -## FROM → TO - -```ts -// before — passed validation; the runtime now refuses the click -header: { - actions: [{ label: 'New Deal', actionType: 'modal', actionUrl: 'create_opportunity' }], -} - -// after — name a declared page… -header: { - actions: [{ label: 'Intake', actionType: 'modal', actionUrl: 'deal_intake' }], // pages: [{ name: 'deal_intake' }] -} -// …or, to open an object's form, use the validated first-class shape -header: { - actions: [{ label: 'New Deal', actionType: 'form', actionUrl: 'opportunity.edit' }], -} -``` - -There is deliberately no automatic rewrite: a retired-shape target is a -name-shaped guess (`create_opportunity` names the page `create_opportunity`, or -it names nothing — the ruling explicitly declined keeping the prefix), and only -the author knows whether the button meant a page or an object form. -`objectstack migrate meta` surfaces the change as a structured TODO (semantic -entry `dashboard-header-modal-target-page-only`, protocol major 18). - - diff --git a/.changeset/datasource-config-mongo-options-credential-refused.md b/.changeset/datasource-config-mongo-options-credential-refused.md deleted file mode 100644 index 0eaa8ca3d2..0000000000 --- a/.changeset/datasource-config-mongo-options-credential-refused.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/service-datasource": patch ---- - -feat(spec): refuse a credential in the mongo options passthrough (`config.options.auth.password`) at publish (#9040) - -**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 FOURTH spelling of the same inline secret: #7990 refused the top-level -`password` key, #8082 the URL userinfo (`user:password@host`), #8337 the -credential-bearing URL query parameters — and the MongoClient `options` -passthrough stayed open one syntax over. -`options: { auth: { username, password } }` parsed green, persisted the -password cleartext into `sys_metadata` (served back by the ordinary data API, -unredacted), and genuinely authenticated: measured on `mongodb@7.5.0`, the -client the driver spreads `config.options` into, the block is transformed into -`MongoCredentials` — so the workaround was live, not inert. - -**What is refused** (write door, closed measured list -`MONGO_OPTIONS_CREDENTIAL_PATHS` behind `credentialFreeMongoOptions`, composed -with the #8336 placeholder refusal on the same slot): a NON-EMPTY STRING -`options.auth.password`, with the binder prescription — and the "wins over" -reassurance is true for this syntax: a bound `external.credentialsRef` secret -outranks the passthrough `auth` block at connect (#8696, measured). -Deliberately not refused, each measured: `auth.username` alone (#8876's -asymmetry — a username is not credential material), an empty password (the -passthrough twin of `user:@host`), every legitimate passthrough option -(`replicaSet`, `tls`, timeouts — byte-identical pins), -`authMechanismProperties.AWS_SESSION_TOKEN` (the v7 client itself throws on it -under MONGODB-AWS and nothing reads it otherwise), and the binder-slotless -client secrets (`proxyPassword`, `tlsCertificateKeyFilePassword`, `key`, -`passphrase`) — refusing those would name a remedy that does not exist (the -binder fills exactly one slot; the turso-`encryptionKey` posture, #8081 -item 4). - -**Read half** (additive, never the substitute — #8082's ruling): stored -passthrough secrets are now redacted on every read exit — -`options.auth.password` plus the binder-slotless names above and -`AWS_SESSION_TOKEN` — reported as dotted `redactedKeys` -(`options.auth.password`), which the metadata write door's generic -carry-forward already walks, so an untouched "Save" keeps the stored -credential on both admin doors (`restoreRedactedConfig` mirrors per leaf). -The #8155 credential-migration planner refuses a stored passthrough-credential -row with the per-row remedy instead of planning `nothing-to-migrate` over live -cleartext (dropping only the nested leaf would leave an `auth` block the -client refuses at construction, measured). - -## FROM → TO - -```yaml -# before — parsed green; password stored cleartext in sys_metadata and -# resolved into MongoCredentials at connect -driver: mongodb -config: - url: mongodb://app@mongo.internal:27017/events - options: - replicaSet: rs0 - auth: { username: app, password: PLAINTEXT-IN-METADATA } - -# after — rejected with the binder prescription; bind the secret instead -driver: mongodb -config: - url: mongodb://app@mongo.internal:27017/events - options: - replicaSet: rs0 -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 — and auto-dropping only the nested password -would leave an `auth` block the MongoDB client refuses outright. - - 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/datasource-credentialsref-mongo-url-no-user-refused.md b/.changeset/datasource-credentialsref-mongo-url-no-user-refused.md deleted file mode 100644 index f67a7c5dec..0000000000 --- a/.changeset/datasource-credentialsref-mongo-url-no-user-refused.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): refuse the contradictory pair "`external.credentialsRef` bound + a mongo `config.url` naming no user" at publish (#9041) - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`, like the sibling refusals #8337 -and #9040; the migration prescription is registered under protocol major 18, -where `os migrate meta` users will look). - -The "absence must be loud" half of the #8696 family, previously unserved: -after #8696 a mongo datasource that binds `external.credentialsRef` and -authors a `config.url` gets the secret injected as MongoClient `auth` — but -`auth` needs a username as well as a password, and with `url` present the only -place the username can come from is the URL's own userinfo. So the injection -is conditional on the URL naming a user: - -- `mongodb://app@db.internal:27017/app` + bound secret → injected, correct; -- `mongodb://db.internal:27017/app` + bound secret → **nothing happens** — the - datasource connects anonymously and the operator is told nothing. - -The second shape is a configuration that cannot work as written; it is now -refused at the datasource level (`DatasourceSchema`'s refinement — the one -door that sees both halves at once; a config-level refinement cannot, because -`credentialsRef` sits on the datasource and `url` inside `config`). The -refusal names BOTH valid authoring fixes without prescribing either: add the -username to the URL, or drop the binding. - -**Scope fences, each measured**: mongodb arm only, legacy `driver: 'mongo'` -rows judged identically via `resolveDriverId` (the postgres arm injects on a -user-less DSN by its own measured mechanism, #8873, and is not assumed to -share the defect); "names no user" means `urlUserinfoUsername` answers -`undefined` — the present-but-empty userinfo forms already throw in -MongoClient itself (`MongoParseError: URI contained empty userinfo section`); -an empty-string `credentialsRef` is not a binding (mirrors the connect path's -truthy check); the composed branch (no `url`) is untouched — its discrete -`username` field is live. Injecting a fabricated empty username instead of -refusing was measured worse on mongodb@7.5.0: it turns a connection that works -anonymously today into a guaranteed handshake failure. Composes independently -with the sibling refusals (#8082 userinfo, #8336 placeholders, #9040 options -passthrough) — one artefact violating several reports each at its own path. - -## FROM → TO - -```yaml -# before — parsed green; the binding was a silent no-op and the datasource -# connected anonymously with the bound secret unused -driver: mongodb -config: - url: mongodb://mongo.internal:27017/events -external: - credentialsRef: sys_secret:01J9ZK4T2N - -# after (authenticated intent) — name the user in the URL; the bound secret -# is injected at connect (#8696) -driver: mongodb -config: - url: mongodb://app@mongo.internal:27017/events -external: - credentialsRef: sys_secret:01J9ZK4T2N - -# after (anonymous intent) — drop the binding that could never land -driver: mongodb -config: - url: mongodb://mongo.internal:27017/events -``` - -There is deliberately no automatic rewrite: the two fixes are contradictory -intents — authenticate (add the username) versus anonymous (drop the binding) -— and choosing between them requires knowing what the datasource is for. - - 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/driver-sql-mysql-unresolvable-column-parity.md b/.changeset/driver-sql-mysql-unresolvable-column-parity.md deleted file mode 100644 index ab68e4d987..0000000000 --- a/.changeset/driver-sql-mysql-unresolvable-column-parity.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@objectstack/driver-sql": minor ---- - -feat(driver-sql): MySQL joins the unresolvable-column predicate — the `INVALID_FILTER` refusal envelope AND the #3821 recoveries, full dialect parity (#8926) - -**BREAKING** accept-set change on a GA public data API — in both directions at -once, on MySQL only — shipped as `minor` under the lockstep launch-window -convention, like the #8790 change it completes. - - - -## What changes, on MySQL only - -`isUnresolvableColumnError` — the ONE predicate `SqlDriver.findRows()`'s #3821 -recovery ladder and `SqlDriver.count()` both read — now recognises MySQL's -spelling of "the statement named a column the backend could not resolve": -`Unknown column 'x' in 'where clause'` / `'field list'` / `'order clause'` -(`ER_BAD_FIELD_ERROR`). SQLite and Postgres behaviour is untouched. - -Measured on live MySQL 8.0.46 (`SqlDriver` over mysql2), before → after: - -- **WHERE** — `find()` and `count()` alike: raw `ER_BAD_FIELD_ERROR`, no - `status` (an unclassified 5xx at the REST boundary), the statement's bound - literals inlined in the message → refused with `INVALID_FILTER` / 400 naming - the column; the dialect message goes to the server log. The narrowing — the - #7929 predicate-text disclosure shape closed on the last dialect that still - had it. -- **Projection** — `find({ fields: [...] })` naming a column the table lacks - threw the raw error → retries selecting `*`; the rows come back, WHERE - honoured. The widening. -- **ORDER BY** — sorting by a column the table lacks threw the raw error → - drops the sort and returns the rows unordered, WHERE honoured. The widening. - -Both directions were ruled together on #8926 (option A, maintainer, -2026-08-16); a split predicate — the envelope without the recoveries — was -refused. The widening cannot drop a predicate: every ladder rung is rebuilt -from `buildBase()`, which unconditionally re-applies `query.where`, so the -recoveries reach the projection and the sort only. - -## Migration - -Nothing stored needs rewriting. A MySQL caller that relied on catching the raw -`ER_BAD_FIELD_ERROR` from `find()`/`count()` should catch `INVALID_FILTER` / -400 instead — the same envelope SQLite and Postgres already answer, and the -same prescription the registered -`driver-sql-unresolvable-where-column-refused` migration carries. diff --git a/.changeset/driver-sql-unresolvable-where-column-refused.md b/.changeset/driver-sql-unresolvable-where-column-refused.md deleted file mode 100644 index 13af483db8..0000000000 --- a/.changeset/driver-sql-unresolvable-where-column-refused.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -"@objectstack/driver-sql": minor -"@objectstack/spec": minor ---- - -fix(driver-sql): one unresolvable WHERE column, one answer — `find()` and `count()` both refuse with `INVALID_FILTER` / 400 naming the column (#8790) - -**BREAKING** accept-set narrowing on a GA public data API, shipped as `minor` -under the lockstep launch-window convention. The migration prescription is -registered under protocol major 18, where `os migrate meta` users will look. - - - -## The defect - -One predicate had two answers. `SqlDriver.findRows()` carries the #3821 -unknown-column recovery ladder, and every rung of it is built from -`buildBase()`, which **always re-applies `query.where`**. So the ladder can drop -a projection and can drop an ORDER BY, but it can never drop the clause that -actually failed when the unresolvable column is in the WHERE — both rungs raise -the same error and the method fell to `return []`. `SqlDriver.count()` runs a -separate statement and has no ladder at all, so the identical predicate threw. - -Measured on a real `SqlDriver` over better-sqlite3, one table, one seeded row: - -``` -where { 'title.x': 'y' } - find() -> 0 rows, NO ERROR - count() -> THREW code=SQLITE_ERROR status=undefined - select count(*) as `count` from `task` where `title`.`x` = 'y' - - no such column: title.x - -CONTROL where { title: 'Design' } - find() -> 1 row - count() -> 1 -``` - -A list view calls both halves, so one query produced an empty page from the rows -half and a 500-shaped failure from the total half. A caller reading only the rows -got a silent empty page that says "no records exist" for what was really "your -predicate never ran" — the single most AI-legible failure to get wrong, since an -agent reads "no matching records" and writes its next query on that belief. - -The thrown half was no better: the dialect's own `code`, no `status` (so an -unclassified 5xx at the REST boundary rather than a caller mistake), and the -statement's **bound literals inlined in the message** — the same predicate-text -disclosure shape #7929 redacted elsewhere. - -## The fix - -Ruled 2026-08-15 on #8790: **refuse both halves** with `INVALID_FILTER` / 400, -naming the column. That envelope is not minted here — it is what every sibling -refusal on this path already answers, required on both SQL drivers by -`cross-field-conformance-cases.ts` and pinned by -`sql-driver-boolean-identity.test.ts` and -`sql-driver-cross-field-conformance.test.ts`. What closes is a -declared-vs-enforced gap, not a new posture. - -The caller-visible message names the column and the object and nothing else. The -dialect's own message — the compiled statement, bound literals and all — goes to -the **server log** instead, so the operator keeps the debugging aid that -`count()`'s raw throw used to provide without it reaching the caller. - -**The #3821 ladder keeps both of its recoveries.** Only the WHERE-failure -terminal `return []` became a refusal, and the asymmetry is the ruling rather -than an oversight: "rows matter more than their order" is an argument about how -rows are *presented*, and it does not transfer to a predicate. A dropped sort is -a correct answer in an unhelpful order; a dropped WHERE is records the caller -explicitly excluded. Recover-both was rejected for exactly that reason. - -## Reach, stated rather than assumed - -The refusal fires on the wordings the ladder has always recognised — SQLite -(`no such column: x`) and Postgres (`column "x" does not exist`). MySQL spells -the condition `Unknown column 'x' in 'where clause'`, which neither arm matches, -so on MySQL an unresolvable column still travels out as the raw dialect error. -That gap is pinned as a fact in the new suite and filed separately: widening the -predicate would also hand MySQL the #3821 projection and ORDER-BY recoveries it -has never had, which is an accept-set change in the opposite direction from this -one. - -## Who is affected - -Callers that reach the driver with a filter key the table has no column for. The -ingress doors already refuse this where they can judge — `assertFilterFieldsExist` -(`@objectstack/metadata-protocol`) answers `INVALID_FIELD` / 400 for everything -reaching `findData`, with the sentence this refusal now echoes verbatim: *a -filter on a field that does not exist can only match zero records, so the query -was refused instead of answered with an empty list*. What changes is the -backstop underneath them: a registry the door could not read, and a dotted key -judged on its head segment only. diff --git a/.changeset/eighty-jars-shave.md b/.changeset/eighty-jars-shave.md deleted file mode 100644 index 38d283ff77..0000000000 --- a/.changeset/eighty-jars-shave.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -Publishing a package no longer promotes another package's draft row. - -`publishPackageDrafts` lists a package's pending drafts with -`listDrafts({ packageId })`, but the promotion then re-resolved each row without -the ADR-0048 `package_id` dimension. Overlay rows are keyed by -`(org, type, name, package_id)` precisely so two installed packages shipping the -same name keep separate rows, so that lookup could not tell them apart: with two -packages holding drafts for the same `(type, name)`, publishing package A -promoted package B's unreviewed draft to active, drained B's draft row, recorded -it under A's ADR-0067 commit and ADR-0010 audit row, and left A's own edit still -pending — while answering `success: true`. Which of the two rows won was -driver-order dependent, so on a real driver this was a coin toss per publish. - -The listed row's `package_id` is now threaded through to the promotion, which -resolves and drains the draft under the same key it was listed by. Publishes -that name no package (`publishMetaItem`) are unchanged. 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/engine-dotted-filter-refused.md b/.changeset/engine-dotted-filter-refused.md deleted file mode 100644 index a94e2783db..0000000000 --- a/.changeset/engine-dotted-filter-refused.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/metadata-protocol": minor -"@objectstack/objectql": minor ---- - -feat(objectql,metadata-protocol): refuse a dotted filter key whose head is a relation, a formula, or a plain scalar — at both doors (#8371) - - - -**BREAKING** accept-set narrowing on the FILTER axis, 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 `objectstack migrate -meta` users will look). - -FILTER was the last of the four query axes with no verdict for a dotted name: -SORT refuses it (#4256), PROJECTION refuses it at both doors (#7589), while -`where: { 'project_id.name': 'Apollo' }` cleared the unknown-field check on its -head segment and answered `200` with zero rows. Measured across all three -drivers before ruling (#8371): relation-head, formula-head, system-column-head -and plain-scalar-head dotted filters return zero rows on `driver-memory`, -`driver-sql` and `driver-mongodb` alike — a lookup stores the related record's -scalar id, so there is no working capability for this refusal to remove; every -answer was a silent empty list indistinguishable from an empty table, and the -virtual case answered one unserviceable intent two ways by spelling -(`{is_open: true}` refused since #8296, `{'is_open.x': true}` not). - -**What is refused:** a dotted filter key whose head field is a relation -(`lookup`/`master_detail`/`user`/`tree`), a virtual `formula`, or a plain -scalar — `400 INVALID_FIELD`, naming the whole offending key, at both the REST -ingress (`assertFilterFieldsExist`) and the engine's own filter seam -(`assertFilterIsMaterializable`, reached by saved reports, flows and dashboard -widgets whose filters never pass the ingress). Both doors judge the head by the -shared `@objectstack/spec/data` classification (`classifyDottedFilterHead`, -new export), so they cannot drift apart. Precedence mirrors the sort axis: -`unknown` > `dotted` > unmaterializable. - -**What stays accepted:** a dotted path into a structured/JSON head -(`{'address.city': 'Beijing'}`) — deliberately unjudged per the ruling, since -it genuinely works on two of three backends; array-valued and file heads, for -the same reason; the nested-relation OBJECT form `{ owner: { region: 'NA' } }`; -and every undotted spelling, byte-identically. - -## FROM → TO - -```ts -// before — 200, zero rows, indistinguishable from an empty table -await engine.find('task', { where: { 'project_id.name': 'Apollo' } }); - -// after — 400 INVALID_FIELD naming 'project_id.name', with the remedy: -// denormalise the value onto a stored field of the queried object and -// filter that (or, to test the relation itself, filter the head field): -await engine.find('task', { where: { project_id: apolloId } }); -``` - -There is deliberately no automatic rewrite: the platform cannot invent the -stored column the remedy prescribes, and it must not join or post-filter -instead — the drivers have already applied `limit`/`offset`, so a post-hoc -predicate would filter an arbitrary page. diff --git a/.changeset/error-leak-mysql-phrasings.md b/.changeset/error-leak-mysql-phrasings.md deleted file mode 100644 index 248dc57f10..0000000000 --- a/.changeset/error-leak-mysql-phrasings.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -"@objectstack/types": patch ---- - -fix(types): teach the internal-leak predicate MySQL's three error templates (#8739) - -`looksLikeInternalErrorLeak` decides whether a message is a driver dump that -must not reach an API client. It is applied at three HTTP boundaries -(`@objectstack/rest`'s `mapDataError`, `@objectstack/runtime`'s -dispatcher-plugin and endpoint-executor, the hono adapter) and by -`@objectstack/objectql`'s log redactor. Its dialect list covered the SQLite -family and Postgres; on a MySQL deployment it returned `false` for every one of -these conditions — **silent, not clearing**. - -Under the maintainer's 2026-08-15 ruling on #8739, **MySQL is a supported -deployment target**, not merely a tested dialect — the answer already implied by -what is published (`OS_DATABASE_DRIVER=mysql` as a documented deployment knob, -`MysqlConfig` as authorable datasource config, per-field MySQL DDL in -`types.mdx`) and by a required CI check that stands up a live `mysql:8.0`. A -supported target's driver text reaches those boundaries in production, so its -templates belong in the list. - -**Now recognised** — one per condition the other two dialects were already -covered for, each anchored on MySQL's own errmsg template rather than on a bare -substring: - -- `Table 'app.t' doesn't exist` (ER_NO_SUCH_TABLE 1146). MySQL's contracted - spelling quotes `db.table` as one identifier, so the Postgres - `relation "t" does not exist` limb could never reach it. -- `Unknown column 'c' in 'field list'` (ER_BAD_FIELD_ERROR 1054). Both quoted - parts are required; the second is MySQL's clause name (`field list`, - `where clause`, `order clause`, `on clause`), and it is what distinguishes the - driver's template from a sentence that merely calls a column unknown. -- `Duplicate entry 'x' for key 'i'` (ER_DUP_ENTRY 1062). The `for key` tail plus - a quoted index is the anchor. This is the one MySQL template whose text embeds - a **caller's value** rather than an identifier — SQLite's - `UNIQUE constraint failed: t.c` and Postgres' `violates unique constraint "…"` - both name only an index — which is why closing this gap was worth a behaviour - change rather than another comment. - -**Deliberately still NOT recognised**, so the boundary of the change is on the -record rather than inferred: - -- **MySQL's ACL family** — `Access denied for user 'u'@'h' to database 'd'` - (1044), `SELECT command denied to user … for table 't'` (1142) — the - counterpart of the Postgres `permission denied for table` limb. Nothing in - this repo has raised one off a live server, and the standing rule in this - neighbourhood (`unique-violation.ts`) is that a dialect's spelling is added - once it has been MEASURED off a thrown error, never from a reading of the - manual. `Access denied` also collides with this platform's own security prose - (`[Security] Access denied: …`), so a guessed pattern here would over-match — - and over-matching suppresses diagnostics an operator needs. -- **MSSQL and Oracle** — `Invalid object name 'sys_metadata'.`, - `ORA-00942: table or view does not exist` still return `false`. -- **Prose that shares the keywords without the driver's anchoring** — an import - summary saying `duplicate entry in the uploaded file`, a mapping message - saying `Unknown column in the uploaded CSV header`, `The table you selected - does not exist`. Pinned as negative cases, because a phrasing list that says - "leak" too often replaces real answers with `Internal server error`. - -**The `false`-means-UNCOVERED rule survives the change and keeps a live -subject.** A `false` here has never meant the text is safe, only that the -predicate never learned that dialect — the reading a reviewer on PR #8737 got -wrong while sizing a disclosure residual, which is what produced this card. The -four `toBe(false)` pins PR #8824 planted as a tripwire for this exact moment -went red as designed and are rewritten, not deleted: the same three measured -messages now assert `true`, so a future change that silently drops MySQL -coverage fails there, and a second block keeps the original `false`-means- -uncovered shape pointed at MSSQL and Oracle. `declaresServerFault` remains the -phrasing-independent answer. - -**No status mapping moves.** `@objectstack/rest` answers the 409 conflict -question with `isUniqueViolationError`, above and independently of this -predicate (#6250), so a MySQL duplicate-entry error is still `409 -UNIQUE_VIOLATION` and a MySQL unknown-column error is still `400 INVALID_FIELD` -— both decided before the leak branch is reached. The log redactor is unchanged -too: a bare MySQL diagnostic carries no knex ` - ` separator, so there is no -statement to cut. Measured across the predicate's full consumer set — types, -objectql, rest, runtime, metadata-protocol, hono, service-package, -service-analytics — the only verdicts that moved are the two that measure this -predicate directly. - -No live MySQL deployment leaking through these boundaries was measured; this -closes a gap in what the boundary recognises, and the card is explicit that no -leak was demonstrated. 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-currency-guidance.md b/.changeset/field-currency-guidance.md deleted file mode 100644 index 05d54cc889..0000000000 --- a/.changeset/field-currency-guidance.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): `FieldSchema` points a bare `currency` key at the declarable `currencyConfig` form (#8163) - -`currency` has never been a declared `FieldSchema` key — only `currencyConfig` -is. Writing the natural spelling was always a loud parse error, but a **bare** -one: the rejection carried only the surface history line ("Until #4001 closed -this shape these were dropped silently…"), with no pointer to the declarable -form. The spelling is not hypothetical — objectui's `resolveFieldCurrency` -reads `field.currency` first from looser grid/column configs, so it circulates -in configs an AI author will have seen. - -The target is a NESTED key (`currencyConfig.defaultCurrency` under -`currencyMode: 'fixed'`), which a flat `aliases` rename cannot express — so -this is prose (`guidance`), the same `storageNotNull`-style case already on -this surface: `currency` is not a field key; a fixed currency is declared as -`currencyConfig: { currencyMode: 'fixed', defaultCurrency: '…' }`. A field -without one uses the tenant default at runtime. - -Accept/reject is byte-for-byte unchanged — `currency` was rejected before this -change and stays rejected after it; only the rejection's message gains a -prescription. diff --git a/.changeset/field-related-list-filter.md b/.changeset/field-related-list-filter.md deleted file mode 100644 index 50c2f3c00a..0000000000 --- a/.changeset/field-related-list-filter.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/lint": minor ---- - -feat(spec): field-level `relatedListFilter` — a declarative default filter for auto-derived related lists (#8704) - - - -The field-level related-list family (`relatedList` / `relatedListTitle` / -`relatedListColumns`) gains its fourth member, `relatedListFilter` — closing the -gap where the only way to filter an auto-derived related list was to abandon the -auto-derived record page for a hand-written `record:related_list` page -(maintainer ruling 2026-08-15 on #8704). - -- **No new filter dialect**: the key carries the canonical Query-DSL - `FilterCondition` (the same authoring face as a query `where`, dataset scope - filters, and `summaryOperations.filter`). The FILTER-axis doors therefore - apply automatically — the schema door refuses bare date-range preset - comparands in ordering positions at parse (#8793), and the engine doors judge - the composed query at run time (`formula` keys refused `INVALID_FIELD`, - #8296). -- **Contract semantics, pinned**: the declared constraint is AND-composed with - the parent-relationship condition `{ [referenceField]: parentId }` — an - authored constraint, never a user-editable suggestion — and the related-list - tab badge count honors the same composed filter, so counts match visible - rows. Both clauses are normative in the key's contract text and pinned by - tests. -- **`@objectstack/lint`**: the shared authored-filter walk (`FILTER_KEYS`) now - recognizes `relatedListFilter`, extending the filter-token, empty-combinator - and preset-comparand rules to the new position. - -The consumption half (RecordDetailView auto-derivation + tab badge) is -objectui#4664, `Blocked-by:` this change; until it lands the key is ledgered -`planned` with an author warning. 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/fieldschema-placeholder-declared.md b/.changeset/fieldschema-placeholder-declared.md deleted file mode 100644 index 24f6daba43..0000000000 --- a/.changeset/fieldschema-placeholder-declared.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `placeholder` becomes a declared `FieldSchema` key — the producer moves to meet four shipped objectui render surfaces (#9019, maintainer Option C ruling on objectui#4676) - - - -`FieldSchema` refused `placeholder` by name ("never a FieldSchema key. Author -hint text through `inlineHelpText` or `description`.") while four objectui -packages plus `apps/console` — plugin-form's auto-generated and sectioned -forms, plugin-detail's inline edit, app-shell's field-backed action params -(whose module header documents the inheritance as intended), and console's -FormPage — apply an object-field-level `placeholder` at render time, feeding -the `@object-ui/fields` widgets. That was the preview-renders/save-422s trap: -the designer preview rendered the key, `PUT /api/v1/meta/object/:name` -refused it. - -Per the 2026-08-16 maintainer ruling (Option C on objectui#4676, measured in -its report comment 5301288148): - -- `placeholder` is now a declared optional string key on `FieldSchema`, with - the semantics the renderers already implement: in-input placeholder text - (the HTML `placeholder` attribute), distinct from `inlineHelpText` - (always-visible help beside/under the input) and `description` (tooltip). -- The `FIELD_KEY_GUIDANCE` retirement entry steering authors away from the key - is removed — after this change that prose would contradict the contract. -- The Studio metadata forms (`object.form.ts` quick-add grid, `field.form.ts` - full editor) offer the key, and the liveness ledger carries a `live` verdict - with the measured cross-repo evidence. - -The matching translation surface (`FieldTranslation.placeholder`) was already -declared, so a translated placeholder now has a declared base key to land on. diff --git a/.changeset/filter-preset-comparand-refused.md b/.changeset/filter-preset-comparand-refused.md deleted file mode 100644 index ebe03d01dc..0000000000 --- a/.changeset/filter-preset-comparand-refused.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/lint": minor ---- - -feat(spec,lint): refuse a bare date-range preset name in an ordering filter comparand at publish time (#8793 — the ruled C half of #8690) - -**BREAKING** accept-set narrowing on a published authoring surface, 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). - -`last_7_days` / `last_30_days` / `last_90_days` and their ten calendar -siblings are real, declared preset names — for the dashboard date-filter -positions, where the console lowers them to `{date-macro}` bounds before any -query is sent. Authored as a bare filter comparand nothing resolves them: -measured on #8690, `$gte "last_30_days"` returned HTTP 200 with 0 of 51 rows -where `$gte "{30_days_ago}"` returned the 38 in-window. The engine now -refuses the bare name on a declared temporal field at query time -(`INVALID_FILTER` / 400, PR #8808 — the B half); this change is the -authoring-time half the same ruling shipped alongside it. - -**What is refused — ordering positions only, in all three authored filter -shapes:** a `$gt` / `$gte` / `$lt` / `$lte` comparand or `$between` endpoint -on every carrier of `FilterConditionSchema` (dashboard widget filter, dataset -filter, report `runtimeFilter`, page/component filter, rollup filter), a -`greater_than` / `less_than` / `before` / `after` / `between` view filter -rule value, and an ordering `[field, op, value]` filter triple (the latter -two via `@objectstack/lint`'s new gating rule `filter-preset-comparand`, -which also runs at the runtime publish gate for `dashboard` / `view` / -`object` / `page` / `flow` writes). The refusal names the offending value, -the position, and the exact `{date-macro}` window that works. - -**What stays accepted:** the preset names in the dashboard date-filter -positions (`dateRange.defaultRange`, a date global filter's `defaultValue`) — -the only positions any layer ever resolved them; equality and membership -comparands (`{ period: 'this_quarter' }`, `$in: [...]`) — a select/picklist -column legitimately stores colliding values, and the engine's field-typed -door already covers the temporal case; undeclared strings -(`'not-a-date-at-all'`) — the field-typed engine door owns those; and the -empty-string cell, which stays its own card by ruling. - -## FROM → TO - -```ts -// before — parsed green, returned a silent zero (or 400 at query time since #8808) -filter: { closed_at: { $gte: 'last_30_days' } } - -// after — rejected naming the window; write the date-macro spelling -filter: { closed_at: { $gte: '{30_days_ago}' } } -// calendar presets prescribe their pair: -filter: { closed_at: { $between: ['{week_start}', '{week_end}'] } } -``` - -`DATE_RANGE_PRESETS` moved to `@objectstack/spec/data` -(`data/date-range-presets.ts`) with `ui` re-exporting it, so both import -paths keep working; `DATE_RANGE_PRESET_MACRO_WINDOWS` (the per-preset macro -window table the refusals quote) and `isDateRangePresetName` are new exports. - - 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/icontains-dialect-parity.md b/.changeset/icontains-dialect-parity.md deleted file mode 100644 index 3e768cd71a..0000000000 --- a/.changeset/icontains-dialect-parity.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `icontains` joins the view and infix filter vocabularies, closing the dialect gap on the capability every driver executes (#8934) - -`$icontains` has been executable on every driver and evaluation face since -#5702/#6520, yet it was authorable from exactly one of the three filter -dialects — the MongoDB-style `FieldOperatorsSchema`. Maintainer ruling -(Option A on #8934): the two remaining vocabularies gain the canonical -spelling. - -- `VIEW_FILTER_OPERATORS` (`ui/view.zod.ts`) gains `icontains`, so a - `ViewFilterRule` can declare a case-insensitive contains. No alias rows: - the alias table bridges spellings already living in stored metadata, and a - new canonical operator has none. -- `AST_OPERATOR_MAP` (`data/filter.zod.ts`) gains `icontains` → `$icontains`, - so `isFilterAST` accepts the infix spelling and `parseFilterAST` lowers it - to the operator the drivers already run. `canonicalAstOperator` round-trips - it through the generic path (`CANONICAL_INFIX` row added). -- Boundary preserved, per the ruling: `icontains`/`$icontains` (LIKE-escaped - substring — a comparand `%` is a LITERAL) and `ilike`/`$ilike` (raw LIKE - pattern) are NOT aliases of each other in either vocabulary, and there is no - `not_icontains` — the `$` dialect has no `$notIcontains`, and the authoring - vocabularies mirror the executed set rather than widening it. -- The parity suite (`filter-view-operator-parity.test.ts`) and - `FILTER_TEXT_CASES` extend accordingly, including a conformance case that - lowers the infix spelling and pins `%`-literalness on every backend that - runs the table. The comparand-type door already judged `$icontains` - (a `FieldOperatorsSchema` key since #5701) — no change needed there. diff --git a/.changeset/identity-api-key-schema-retired.md b/.changeset/identity-api-key-schema-retired.md deleted file mode 100644 index 5c7b80b839..0000000000 --- a/.changeset/identity-api-key-schema-retired.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire `ApiKeySchema` — the identity module no longer publishes a second, fictional declaration of `sys_api_key` (#8715, ADR-0049) - - - -**BREAKING** public-surface removal, 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 #8586 precedent). - -`ApiKeySchema` (and its `ApiKey` / `ApiKeyParsed` types) documented -better-auth's `apiKey` **plugin** schema — a plugin this platform does not -load: `start` and `lastRefetchAt` name columns that do not exist; `enabled` -inverts the real `revoked` column's polarity; `rateLimitEnabled` / -`rateLimitTimeWindow` / `rateLimitMax` / `remaining` advertise a per-key -rate-limit capability nothing implements; `permissions` and `metadata` have no -columns; `organizationId` is camelCase fiction next to the real snake_case -`active_organization_id`. Zero consumers anywhere in the monorepo outside its -own unit test — one table had two declarations, and the published one was -fiction (maintainer-ruled DELETE, 2026-08-15). - -**What breaks:** `import { ApiKeySchema, ApiKey, ApiKeyParsed }` from -`@objectstack/spec` or `@objectstack/spec/identity` is TS2305 after upgrade. -The generated reference page's `ApiKey` section and the 19 -`identity/ApiKey:*` authorable-surface keys disappear with the schema. - -**What stays:** everything real. The single declaration of `sys_api_key` is -the ObjectSchema in `@objectstack/platform-objects` -(`identity/sys-api-key.object.ts`) — columns `name, prefix, user_id, -active_organization_id, scopes, expires_at, last_used_at, revoked, key, id, -created_at, updated_at`; rows are minted by `POST /api/v1/keys` and verified -by `core/src/security/api-key.ts`, keyed by the `osk_` prefix. Neither ever -read the deleted schema, so runtime behaviour is byte-identical. -`UserSchema` / `AccountSchema` / `VerificationTokenSchema` and the -organization module survive unchanged. - -The retirement kit: - -- schema deleted in place, with the in-module explanatory block naming the - live declaration (`packages/spec/src/identity/identity.zod.ts`) -- ADR-0087 registration: retired-def entry `identity/ApiKey` + D3 semantic - entry `identity-api-key-schema-retired`, both under protocol 18 (route 3 — - no carrier key and no authored document, so no tombstone and no D2 - conversion; the registry entries ARE the declaration) -- pin tests: `identity/api-key-retirement.test.ts` (zero holders on every - public entry, survivors stand) and platform-objects' - `sys-api-key-single-declaration.test.ts` (the real column set, spec's - runtime namespace lost the name) -- generated baselines regenerated: authorable surface (−19 keys), JSON-schema - manifest (−1 def), api-surface / export-origins (−3 names), reference docs -- `cloud/developer-portal.zod.ts` prose corrected: marketplace API keys point - at the `sys_api_key` object and `POST /api/v1/keys`, not at - `Identity.ApiKeySchema` (the marketplace-key plan is ruled not live) - -## FROM → TO - -```ts -// before — type-checked green against a schema no runtime ever read -import { ApiKeySchema, type ApiKey } from '@objectstack/spec/identity'; -const key: ApiKey = { id, name, userId, enabled: true, rateLimitMax: 100, /* … */ }; - -// after — read the real table: the sys_api_key ObjectSchema in -// @objectstack/platform-objects (snake_case, `revoked` not `enabled`); -// mint via POST /api/v1/keys, verify via core/src/security/api-key.ts. -import { SysApiKey } from '@objectstack/platform-objects'; -``` 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/install-local-capability-gate.md b/.changeset/install-local-capability-gate.md deleted file mode 100644 index d18aad7734..0000000000 --- a/.changeset/install-local-capability-gate.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -"@objectstack/cloud-connection": minor ---- - -fix(cloud-connection): the four mutating `install-local` routes require the `manage_metadata` capability, and the `x-user-id` header fallback is gone (#8976) - - - -**BREAKING for any integration that installs, uninstalls, reseeds or purges a -local marketplace package with a principal holding no authoring capability — and -for anything that identified itself to these routes with an `x-user-id` header.** -Landing after the v17.0.0 cut, so it ships as `minor` under the lockstep -launch-window convention. - -`MarketplaceInstallLocalPlugin`'s `requireAuthenticatedUser` asked one question — -"is there a session?" — and it was the only check on all four mutating routes: - -- `POST /api/v1/marketplace/install-local` — accepts an **inline manifest**, - hot-registers its objects into the shared registry, runs `syncSchemas()` - against the shared database, writes the install ledger and runs seed data; -- `DELETE /api/v1/marketplace/install-local/:manifestId`; -- `POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data`; -- `POST /api/v1/marketplace/install-local/:manifestId/purge-sample-data`. - -It also ended in a fallback that trusted a bare **`x-user-id` request header**, -commented as being "for cases where auth is disabled (e.g. test stubs)". - -**Measured through the composed plugin, to the point the state actually changes** -— `manifest.register()`, `objectql.syncSchemas()`, the ledger file on disk, -`SeedLoaderService.load()`, `driver.delete()`. All three principal shapes were -indistinguishable, and every effect fired for every one of them: - -| principal | install | reseed | purge | uninstall | -|:--|:--|:--|:--|:--| -| bare `x-user-id` header, **no session** | **200** | **200** | **200** | **200** | -| authenticated, **no** `manage_metadata` | **200** | **200** | **200** | **200** | -| authenticated, `manage_metadata` | 200 | 200 | 200 | 200 | - -Nothing downstream refused any of it. The first row is the sharper half: with no -session store consulted first, a caller who could reach the port completed a -full schema-mutating install and had `installedBy` recorded as a string of their -own choosing. - -**Severity by deployment shape.** Metadata is environment-scoped rather than -org-scoped, so Layer 0's tenant wall does not reach these writes: on the walled -multi-org EE shape this is a cross-tenant write channel — any signed-up user of -any customer organization could mutate the schema every other tenant runs on, -and `organization_admin` deliberately withholds `manage_metadata` precisely -because a tenant administrator is not supposed to. It also nullified the -already-implemented cloud-side ruling that AI `build` be structurally closed on -that shape: closing the build agent while this route stayed open closed the -front door and left the loading dock unlocked. On a single-org self-host the -severity is genuinely lower — every user is one tenant's — but "any employee -with a login can alter the schema and run seed data" still contradicts the -operator-action framing, and the header fallback admitted callers with no login -at all. The measurements above are code-path measurements through a composed -host, not an exploit demonstrated against a running deployment. - -**The fix.** All four routes now resolve identity **and** capability through -`resolveAuthzContext` — the platform's single authorization resolver -(`@objectstack/core`) — and demand ADR-0066 D1's `manage_metadata`, the same key -the `/meta` write doors carry (#6603, and #8919 for the promotion verbs). A -caller with no resolvable principal gets `401 UNAUTHENTICATED`; an authenticated -caller without the capability gets `403 FORBIDDEN` naming the capability they -need. The refusal is issued before any work, so a refused caller cannot probe -what is installed through a downstream error. Service and operator tokens are -exempt exactly as elsewhere, with no special case: an API key resolves through -the same resolver to its owner's real grants. - -**The `x-user-id` fallback is removed, not mode-gated.** It carried no mode flag -to gate it to, and it was the last `x-user-id` trust left in `packages/**` -source — the two sibling raw-route surfaces that carried the identical line had -it *removed* in favour of this same resolver rather than restricted -(`plugin-sharing`'s share-link routes, `service-settings`' settings routes). The -one first-party caller of these routes, `os package install`, signs in for a -real better-auth session cookie and never sent the header. - -The plugin's mount stays **unconditional** (cloud#1287 moved it out of the -`marketplaceUrl` ternary so air-gapped boxes stop 404ing). This is authorization -on the routes, not un-mounting the plugin. - -**Anti-drift.** `marketplace-install-local-capability-enumeration.test.ts` -derives the mutating routes from the plugin's own route table and compares them -against a declared list, so a new mutating install-local route fails the build -until it is enumerated and its refusal cases run. Each refusal asserts the -ADR-0112 envelope (`code` **and** `status`) *and* that no registry, schema, -ledger, seed or delete effect fired — a gate that answers 403 after -`syncSchemas()` has run is still the bug. - -Two existing suites whose names read as authorization coverage — -`marketplace-install-local-posture-gate.test.ts` (the ADR-0120 D5e ceremony, -which the caller satisfies from their own request body) and -`marketplace-install-local-tenancy-posture.test.ts` (which selects a seeding -path) — now open with an explicit statement of what they do **not** cover and -name the file that does, backed by an assertion that the named file exists so -the correction cannot rot into a wrong answer. Neither test was weakened. 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/lifecycle-governance-probe-8906.md b/.changeset/lifecycle-governance-probe-8906.md deleted file mode 100644 index 9fc4989b06..0000000000 --- a/.changeset/lifecycle-governance-probe-8906.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -lifecycle: a failed governance row-count probe is no longer indistinguishable from a quiet object - -`LifecycleService.checkGovernance()` probed each declared object's row count and swallowed -every failure with a bare `catch { continue }`. A driver outage therefore read exactly like -an object with nothing to alert on: no `quota-exceeded`, no `growth`, nothing logged, and -nothing in the sweep report — and because the failed object also dropped out of the count -map that becomes the next sweep's baseline, the next sweep could not alert on growth for it -either. - -The probe now discriminates by error type through the shared `isMissingTableError` -predicate. An unprovisioned table is truthful emptiness and stays silent; every other -failure is reported per object in the sweep report's existing `errors` list and logged at -`warn`, both naming the lost growth baseline. No new report field, no new error code, and -the sweep is still isolated — one object's failed probe never costs the others their -governance. 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-promotion-capability-gate.md b/.changeset/meta-promotion-capability-gate.md deleted file mode 100644 index 95d5523306..0000000000 --- a/.changeset/meta-promotion-capability-gate.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -"@objectstack/rest": minor ---- - -fix(rest): `POST /meta/:type/:name/publish` and `.../rollback` require the `manage_metadata` capability (#8919) - - - -**BREAKING for any integration that publishes or rolls back metadata with a -principal holding no authoring capability.** Landing after the v17.0.0 cut, so -it ships as `minor` under the lockstep launch-window convention. - -`packages/rest` gates four metadata-authoring doors on ADR-0066 D1's -`manage_metadata` capability — `POST /meta/_migrate-stored`, `PUT /meta/:type/:name` -(#6603), `PUT /meta/:type/:section/:name` and `DELETE /meta/:type/:name` (#7019). -The two **promotion** verbs did not, and promotion is what decides which body is -live: `publishMetaItem` flips the `sys_metadata` row `state: 'draft'` to -`'active'` (ADR-0027 (E)(5) defines sealing a publish as exactly that flip), and -`rollbackMetaItem` restores a caller-supplied `toVersion` as the new live row. - -**Measured through a composed host, down to the protocol layer, before the fix:** - -| principal | publish | rollback | -|:--|:--|:--| -| anonymous | 401, protocol not reached | 401, protocol not reached | -| authenticated, **no** `manage_metadata` | **200, protocol reached** | **200, protocol reached** | -| authenticated, `manage_metadata` | 200, protocol reached | 200, protocol reached | - -So the reachable cohort was every authenticated principal holding no authoring -capability at all: it could take a draft somebody else authored and make it -live, or restore any historical version over the live row. Anonymous callers -were already refused by the `/meta` umbrella (`registerMetadataEndpoints`), so -what these gates add is precisely the authenticated-but-uncapable cohort. - -**`rollback` is the sharper of the two.** The caller supplies `toVersion`, which -makes it a mechanism for reverting security hardening — a permission set as it -stood before it was tightened, a validation rule from before it existed, a -layout from before field-level security. It is also the door with the least -behind it: publish at least re-runs `assertRuntimeAuthoringRules` on the -promoted draft (#4463 D1), while rollback runs no content gate at all. Neither -of those reads the caller in any case — D1 answers "is this metadata valid", not -"may you press this button" — so nothing downstream was ever doing this job. -Audit rows are still written either way, so the action remains traceable after -the fact. - -**No legitimate caller loses anything, and that is measured rather than -assumed.** The Studio designer's save-then-publish loop saves `?mode=draft` and -then POSTs `/publish`, and its **first** step already demanded -`manage_metadata` — so every principal that can author a draft already clears -the new gate. The shipped sets bear this out: `admin_full_access` (the only set -carrying `studio.access`) carries `manage_metadata` too, while -`organization_admin` and `member_default` are refused at the save door **today**. -The only callers the gap benefited were exactly the ones already refused the -authoring door — able to promote a draft they could not have written. - -**Migration — grant `manage_metadata` to any service principal that publishes.** -An integration that promotes metadata on its own schedule (a CI job sealing a -release, an AI authoring agent) needs the capability explicitly; there is no -automatic replacement, deliberately. `isSystem` contexts bypass, as on every -other capability gate on the platform, so in-process callers are unaffected. - -The gate is the sibling doors' four lines verbatim, deliberately not a second -way of demanding the same capability, and it fires **before** the protocol is -resolved so 403-vs-501 leaks no kernel capability and nothing is promoted before -the refusal. - -⚠️ **An author/publisher capability split is NOT introduced here.** Separating -"may write a draft" from "may make it live" is a defensible design, but it needs -a *different* declared capability and is a product decision; both defensible -designs require a gate, and the state this fixes was neither. - -Ships with an **enumeration pin** rather than two assertions. The defect was not -that two handlers forgot a gate — it was that the gate was a convention held by -repetition and nothing else, so the next metadata write door had a one-in-three -chance of copying an ungated neighbour with no test going red. The new suite -derives the write doors from the composed server's own route table and compares -them against a declared list, so a new mutating `/meta` route fails the build -until it is enumerated and its refusal asserted. 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/meta-write-actor-identity-wins.md b/.changeset/meta-write-actor-identity-wins.md deleted file mode 100644 index 08a3c5cb3f..0000000000 --- a/.changeset/meta-write-actor-identity-wins.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -**Audit attribution change — the recorded actor on `/meta` writes is now the authenticated identity, and `X-Actor` is ignored.** All five `/meta` write sites (save, delete/reset, publish, rollback, compound save) stamp `sys_metadata_audit.actor` and `sys_metadata_history.recorded_by` with the identity the request was actually authorized as. A request that sends `X-Actor` is recorded against its own authenticated caller, not the header's value. Maintainer ruling 2026-08-12 on #7941, re-confirmed 2026-08-15. - -Why: the header used to outrank the authenticated identity. That ordering was inert for as long as the other limb produced nothing — `req.user` / `req.userId` are never set on this transport — so nothing depended on it. Fixing that producer (#7749) made the precedence load-bearing for the first time, and what it then meant was that any caller already holding `manage_metadata` could sign somebody else's name to a metadata write: the compliance trail answered "who *claimed* to change this" rather than "who changed this", which is the question #7749 was filed to make answerable. Attribution now cannot drift from authorization, because both read the same `resolveExecCtx` the route's own capability gate reads. - -The header limb is **removed rather than reordered**. The ruling permitted keeping it for genuine machine/system callers with no authenticated user, but only if a consumer census showed that shape exists — it does not, so a caller cannot choose the recorded name in any shape, including on the machine-write path where there is no identity for the header to lose to. - -Deliberately unchanged: - -- **Real impersonation still attributes correctly.** The platform's impersonation is session-level (better-auth admin plugin, `sys_session.impersonated_by`), so `resolveExecCtx` already resolves to the impersonated user and their metadata writes are recorded against them. Nothing in that path went through `X-Actor`. -- **Machine and anonymous writes.** No resolved principal still means no actor, so the protocol's own `'system'` / `NULL` defaults apply exactly as before — a machine write is never stamped with a real user. -- **Sending `X-Actor` is not an error.** It is ignored, not rejected; no request that succeeds today starts failing. - -Who is affected: any caller that relied on `X-Actor` to attribute a `/meta` write to somebody other than itself. The census over `objectstack` and `objectui` found no such caller — `objectui`'s `MetadataClient` can send the header through an optional `options.actor`, but nothing in that repo ever passes one, leaving that option inert against this server. 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/migrate-stored-noncanonical-type-skipped.md b/.changeset/migrate-stored-noncanonical-type-skipped.md deleted file mode 100644 index cc446da52d..0000000000 --- a/.changeset/migrate-stored-noncanonical-type-skipped.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): the stored migration reports a non-canonical stored `type` as `skipped` instead of counting it `canonical` (#8957) - -`migrateStoredMetadata` — the method behind `POST /meta/_migrate-stored` and -`os migrate meta --stored` — opened every row with -`PLURAL_TO_SINGULAR[rawType] ?? rawType`, the **manifest-collection** map. That -map legitimately omits the metadata types that are not stack collections, so -for a row stored under one of their plural spellings the fold was a no-op: the -pass looked up ADR-0087 body conversions registered for a type named `fields`, -found none, saw nothing had changed, and recorded the row `canonical`. - -`canonical` is counted and never itemised — by design, because on a healthy -deployment that is every row — so the row disappeared from `report.rows` -altogether. The verdict means "nothing to do", and there was something to do: -the row sits in a second namespace that no registry read and no compliance -query on the canonical type can reach. - -Since #8908, `publishPackageDrafts` **refuses** exactly these rows at its -pre-flight (`STORED_TYPE_NOT_CANONICAL`). The stored migration is the door an -operator naturally reaches for next, and it answered that the row was already -fine. The two doors now agree. - -## What changed in the report - -The scan folds with the URL/registry map (`canonicalMetaType`) instead of the -manifest map, and a row whose **stored** spelling is non-canonical is reported: - -```jsonc -// before — the row was invisible -{ "scanned": 1, "canonical": 1, "skipped": 0, "rows": [] } - -// after -{ - "scanned": 1, "canonical": 0, "skipped": 1, - "rows": [{ - "type": "field", "name": "showcase_task.title", "outcome": "skipped", - "reason": "the row is stored under the non-canonical metadata type 'fields' ('fields/showcase_task.title'), and its canonical type is 'field'. …" - }] -} -``` - -The reason names the stored spelling in the same `type/name` form the publish -refusal quotes, the canonical type, the other door's error code, and the -re-author path. `--type field` and `--type fields` now both reach the row — -the filter folds the same way, so the spelling an operator was just handed by -the publish refusal is not the one spelling that fails to find it. - -The fold swap cannot change the answer for any spelling the old fold resolved: -`META_URL_TO_SINGULAR` embeds every manifest spelling verbatim under a -module-load agreement assertion, and measured on this tree the set of spellings -where the two folds disagree is empty. The set the new fold newly resolves is -exactly the six-member class `isNonCanonicalStoredType` derives (`fields`, -`seeds`, `external_catalogs`, `externalCatalogs`, `translations`, -`email_templates`), which is the set now reported. - -## What did NOT change - -The method's contract. It still canonicalizes **bodies**, and it still writes -nothing for this class: rewriting a stored `type` is an identity move — a new -`(org, type, name, package_id)` key, history and audit continuity to decide, -and a collision question when the canonical row already exists — which #8908's -ruling parked as a follow-up needing its own appetite. - -`storedMigrationClean` is also unchanged: `skipped` rows still do not flip it. -This pass has no lever for the condition, so failing the verdict over it would -give `os migrate meta --stored` a non-zero exit that no run of that command -could ever clear. The row is reported per-row instead, and the publish door is -what refuses it. diff --git a/.changeset/mongo-dsn-bound-secret-injected.md b/.changeset/mongo-dsn-bound-secret-injected.md deleted file mode 100644 index 8344f7539d..0000000000 --- a/.changeset/mongo-dsn-bound-secret-injected.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -"@objectstack/service-datasource": patch ---- - -fix(security): a mongo datasource that binds `external.credentialsRef` and authors a connection URL now connects with the bound credential instead of none (#8696) - - - -`buildMongoUrl`'s DSN branch returned the authored `config.url` verbatim and -applied `spec.secret` nowhere. A mongo datasource that bound its secret through -`external.credentialsRef` (or the connection form's secret field) therefore -connected with **whatever the URL itself carried** — which, since #8082 refuses -a `user:password@` userinfo at the publish door, is **no credential at all**. -Measured on `origin/main` @ `792524c22`, mongodb 7.5.0: - -```text -config.url 'mongodb://app@db.internal:27017/app' + a bound secret - -> MongoClient credentials {username:'app', password:''} -``` - -The connect path is fail-closed on a ref it cannot resolve, so an operator -reasonably reads "the datasource connected" as "the bound credential was used". -It was not: the credential was declared, resolved, injected into the factory — -and then dropped at the last call site with no diagnostic. That is -declared-≠-enforced (Prime Directive #10) one layer below the spec, and -`MongoConfigSchema.url` is the contract it broke, verbatim: *"bind the secret -(`external.credentialsRef` / the connection form's secret field) and **it is -injected at connect time**. A bare username (`user@host1`) stays writable."* -The arm's behaviour was decided by whether the operator happened to author a -URL — the composed branch five lines below had honoured the secret since #4410. -This closes the last arm of the family #7314 / #7385 / #8152 / #8875 have each -closed one driver at a time. - -**The fix injects `options.auth` beside an unmodified url — it does not rewrite -the URL.** Measured on mongodb 7.5.0 (the `MongoClient` constructor resolves -credentials eagerly, so all of it is assertable with no server): - -```text -'mongodb://app@db.internal:27017/app' + auth{app,BOUND} -> password BOUND -'mongodb://app:embedded-legacy@h/app' + auth{app,BOUND} -> password BOUND -'mongodb://app@h1:27017,h2:27017/app' + auth{app,BOUND} -> password BOUND -'mongodb+srv://app@c0.example.net/app' + auth{app,BOUND} -> password BOUND -'mongodb://app@h/app?authSource=admin' + auth{app,BOUND} -> source admin -``` - -So the authored URL is handed over byte for byte, no second dialect of -`mongodb://…` enters this repo, the multi-host and `+srv` forms ride through -unharmed, and a bound secret **wins** over a legacy password embedded in a -stored pre-#8082 row — the same precedence the mysql arm states, reached by a -different mechanism because the clients merge in opposite directions. The -userinfo **username** `auth` also requires is read through the platform's own -DSN grammar (`urlUserinfoUsername`, #8876) and percent-decoded at the call -site: `new URL()` cannot even parse the multi-host form this schema documents, -and a second hand-rolled copy of those boundaries is the shape #8082's ruling -rejects by name. - -**A URL that names no user gets nothing, deliberately.** `auth` is not -constructible from a password alone, and inventing an empty username is -measurably worse than silence: `mongodb://db.internal:27017/app` carries no -credentials at all today, and would carry `{username:''}` — a guaranteed -handshake failure — if the arm injected regardless. Injection happens only -where the URL already declares authenticated intent, which is also exactly what -the composed branch has always done with the same input. Making that -contradictory pair (a bound `credentialsRef` beside a user-less URL) loud -belongs at the authoring door, where both halves are visible at once; it is -filed rather than guessed at here. - -**Blast radius is exactly the broken class.** A datasource that binds no secret -reaches the client byte-for-byte as before, and the `options` passthrough keeps -arriving verbatim — the injected `auth` is merged into it, not assigned over -it. - -The pin extends `__tests__/bound-secret-dsn-branches.test.ts` (the mysql half's -file) and asserts at the **client-construction seam**: every mongo assertion -reads `MongoClient`'s own resolved `credentials`, never the URL string the -factory built. That distinction is load-bearing — a test asserting -`buildMongoUrl`'s return value would have passed throughout this defect's life, -and the postgres arm passes the equivalent config-layer assertion while still -being broken one layer lower. 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-dsn-ssl-honoured.md b/.changeset/mysql-dsn-ssl-honoured.md deleted file mode 100644 index f6a860e8f4..0000000000 --- a/.changeset/mysql-dsn-ssl-honoured.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/service-datasource': patch ---- - -A mysql datasource that declares TLS now gets it, on both branches of the arm and in the spelling `mysql2` can read (#8874). - -Two defects with one cause — `buildMysqlConnection` resolved the TLS option and then handed it to a client that could not use it, or to nobody at all. - -**A declared `ssl` was dropped on the DSN branch.** With a `config.url` present the arm returned before the resolved option could be attached, so a datasource that declared TLS **and** wrote a connection url negotiated none — declared, resolved, dropped, with no diagnostic — while the discrete-fields branch of the same arm carried it. Whether a connection was encrypted therefore depended on which branch of one arm the datasource happened to take. The postgres arm has honoured this case since #4410 with its reasoning written in-code, and the same argument holds here: `mysql2` reads a uri and the `ssl` option as separate channels, and keeps the explicit key. - -**`ssl: true` was never a `mysql2` value.** Measured on mysql2 3.23.1, `new ConnectionConfig({ …, ssl: true })` throws `SSL profile must be an object, instead it's a boolean` — and `true` is exactly what a declared `ssl: { enabled: true }` with no certificate material resolves to, as does the `config.ssl` shorthand, whose schema is a boolean and so has no other authorable value. The branch that appeared to honour the declaration was therefore throwing on every connection acquisition for the commonest way of writing it. The resolved `true` is now translated to the empty-options object it is already documented to be short for (`{}`, which mysql2 normalises to `{ rejectUnauthorized: true }` — its own default for an object, not a verification policy chosen here). Certificate objects, `false`, and a stored profile name pass through untouched. - -**What does not change.** The DSN branch returns an object instead of the bare connection string **only when a declared `ssl` actually resolved** (or a secret is bound, unchanged from #8696). A datasource that declared neither still gets the byte-identical string knex has always parsed for it. Where the switch does happen, knex's own parse of the string and mysql2's parse of the same value as `uri` were compared key-by-key (`host`/`port`/`user`/`password`/`database`/`charset`/`timezone`/`connectTimeout`/`flags`/`socketPath`/`multipleStatements`) across the bare-username, embedded-password, no-userinfo, portless, percent-encoded-username and query-parameter forms — identical in every case, and pinned as a test rather than measured once. - -Nothing that declared no TLS moves, so the behaviour change is confined to the datasources that were already broken: the ones connecting in cleartext against their own metadata, and the ones that could not connect at all. 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/mysql-upsert-cross-row-identity-merge.md b/.changeset/mysql-upsert-cross-row-identity-merge.md deleted file mode 100644 index 1d868ee7d2..0000000000 --- a/.changeset/mysql-upsert-cross-row-identity-merge.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@objectstack/driver-sql": minor -"@objectstack/spec": patch ---- - -fix(driver-sql): refuse — and roll back — a MySQL upsert that merges onto a row the caller never identified (#8807) - -`ON DUPLICATE KEY UPDATE` carries no conflict target, so on MySQL a merge lands on -whichever UNIQUE key the row collides with first. `#8621` closed the half where -nothing backed a caller-named target; `#8755` closed the half where a rival key -could absorb a caller-named one. This closes the residue those two left by -construction: the `conflictKeys`-less call and the `['id']` call, which compile -byte-identically and which no pre-flight can judge, because neither names anything. - -Measured on live MySQL 8.0.46, `email` and `tax_id` both `unique: true`, **no** -`conflictKeys`: seeding `{email:'d@b.com', tax_id:'T-9'}` inserted one row, and -`{email:'e@b.com', tax_id:'T-9'}` then resolved with no error — one row, the -*seeded* one, its `email` rewritten `d@b.com` to `e@b.com`, and the id the caller -was handed back present in no row at all. The identical pair on SQLite raises -`UNIQUE constraint failed: …tax_id` and leaves the seeded row untouched. - -Per the maintainer ruling on #8807 this enforces a contract principle, not a MySQL -detail: *an `upsert` must never modify a row whose identity the caller did not -supply and whose conflict key it did not name.* - -**Accept-set change, MySQL only.** After the statement and inside the same -transaction, the driver checks whether the row it landed on is the one the call -supplied. If it is not, the write is **rolled back** and the call refuses with -`code: 'VALIDATION_ERROR'`, `status: 400`, naming the UNIQUE key that absorbed the -merge and stating that nothing was changed. - -The check is exact rather than heuristic — `id` is insert-only on the merge path -(#8622), so a row merged on the primary key always still carries the supplied id -and a row merged on any other key never does — which is why it has no false -refusals. - -Deliberately unchanged: tables whose only key is the primary key are not verified -and open no transaction, so the ordinary upsert keeps its single round trip; every -insert and every re-upsert of the same row still merges; the caller-named -single-unique-key fast path is untouched; and SQLite and PostgreSQL are unaffected, -because `ON CONFLICT (...)` already honours the named arbiter. The lifecycle -archiver's hot→cold copy passes by construction — it supplies each row's own id — -and of the two objects declaring `lifecycle.archive`, neither carries a -non-primary unique field. The dialect limit is documented under -*Database Drivers → MySQL*. diff --git a/.changeset/object-index-unknown-keys-refused.md b/.changeset/object-index-unknown-keys-refused.md deleted file mode 100644 index baec056fcf..0000000000 --- a/.changeset/object-index-unknown-keys-refused.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): refuse undeclared keys on object `indexes[]` entries (#4001 批 20 site 14, the held `IndexSchema`) - -**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). - -`IndexSchema` — 批 20's one deliberately-held site — is now `strictObject` like -its thirteen siblings. The hold was a measured #5114-class risk, not an -unfinished to-do: objectui's embedded index editor shipped a drifted -hand-copied schema (`FALLBACK_SCHEMAS.index`) offering `where` for a -partial-index predicate and `brin` in an algorithm enum, spliced its form -output into `object.indexes[]` and PUT the whole object — so closing the shape -would have 422'd a control the console itself rendered. objectui#4772 -converged that editor to the declared surface (`name` / `fields` / `unique`), -spending the hold's evidence. - -Before this change an undeclared key on an index parsed clean and was silently -dropped: an admin filling the old "Partial-index predicate" control got a -green save while no driver ever read the predicate -(`SqlDriver.syncDeclaredIndexes` consumes `name`/`fields`/`unique` only). - -**What is refused:** any key the shape does not declare, with a prescriptive -message naming the surface and the offending key. `where` carries a curated -guidance entry — the predicate belongs at the database layer -(`CREATE [UNIQUE] INDEX … WHERE` from a runtime migration, the -`ensureOverlayIndex` pattern), deliberately NOT a rename onto the retired -`partial` tombstone (a suggestion pointing into a second rejection). - -**What stays accepted:** every declared key byte-identically, including every -ADR-0120 `unique` scope spelling — and the protocol-17 `type`/`partial` -tombstones keep answering their own migration prescription rather than -degrading to a generic `unrecognized_keys`. - -## FROM → TO - -```ts -// before — parsed green; the predicate was silently dropped, the index built FULL -indexes: [{ fields: ['status'], where: "status = 'open'" }] - -// after — rejected with the database-layer prescription; declare only what is materialized -indexes: [{ fields: ['status'] }] -// …and issue `CREATE INDEX … WHERE ` from a runtime migration when -// a partial index is actually needed. -``` - -There is deliberately no automatic rewrite: an undeclared key here either -names a capability the declaration surface does not deliver (blessing it would -be declared-but-unenforced surface, ADR-0078) or is a spelling of a declared -one, which the rejection names. `os migrate meta` surfaces the change as a -structured TODO (semantic entry `object-index-unknown-keys-refused`, protocol -major 18 — this refusal is not part of the v17.0.0 cut). - - 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/partial-field-masking.md b/.changeset/partial-field-masking.md deleted file mode 100644 index e86732d117..0000000000 --- a/.changeset/partial-field-masking.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/plugin-security': minor ---- - -Partial field masking (#8993): `FieldSchema` declares `maskingRule` — a closed -preset enum (`phone`, `id_card`, `bank_account`, `email`, `name`) plus a -`{ keepHead, keepTail }` escape hatch — and plugin-security's `FieldMasker` -enforces it in the same PR (ADR-0049 declare = enforce; the key re-enters the -schema only with its runtime consumer attached, honouring the 2026-06 prune in -spirit). - -A field declaring a rule is served masked-but-recognisable (`138****5678`) to -every non-system caller; the field's `requiredPermissions` (ADR-0066 D3) is the -unmask gate — holders of all listed capabilities read the full value. A -permission set that marks the field non-readable still deletes it entirely. -Masking rides the single runtime channel, so API callers, browser users, the -CSV/XLSX export route and the AI-context interceptor all see the same -deterministic, length-preserving masked value. Masked callers cannot filter, -sort, group or aggregate on the field (403, the FLS predicate-oracle guard), -and a write that round-trips a masked placeholder is refused with -`400 VALIDATION_ERROR` instead of silently overwriting the stored value. -New exports: `FieldMaskingRuleSchema`, `FieldMaskingKeepSchema`, -`FIELD_MASKING_PRESETS`, `maskFieldValue`, `MASK_CHAR`. 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/phantom-anchor-write-deny-diagnostic.md b/.changeset/phantom-anchor-write-deny-diagnostic.md deleted file mode 100644 index 9e5c6bb4c5..0000000000 --- a/.changeset/phantom-anchor-write-deny-diagnostic.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/plugin-sharing': minor ---- - -A write refused because a federated object's `owner_id` is the platform's phantom anchor now says so, once per object (#8418). - -**No verdict changes.** `checkEdit` / `checkDelete` stay fail-closed exactly as shipped — this adds a diagnostic and nothing else. Maintainer ruling 2026-08-13 (option C on #8418): keep `deny`, make the refusal visible. - -What was wrong: on an ADR-0015 federated object with no author-declared `owner_id`, the registry injects the anchor but the platform provisions no column behind it, so the ownership fast path selects `owner_id` off a remote table that does not have it. The SQL driver's recovery ladder DISCARDS a projection naming an unresolvable column and re-runs `select('*')` instead of raising — so `matchesOwnerScope` receives a good row that simply has no `owner_id` key, reads `owner == null`, and refuses. Because nothing threw, `writeGateFailClosed` was never reached and **nothing was logged anywhere**: the operator got a bare 403 with no trace, at every write depth (`org` included — the null-owner short-circuit runs before the scope is consulted). Only a `modifyAllRecords` holder could still write. - -`SharingService` now emits `PHANTOM_ANCHOR_WRITE_DENY_NOTICE` at `warn` on that path, naming the object, the owner field and the caller, with both remedies in the wording: declare the real remote owner column, or move the object off an owner-scoped sharing model. The constant is exported so a deployment can match on it. - -Deduped **per object**, for the service's lifetime. The condition is a property of the registered schema, identical for every row and every caller, so a bulk write emits one line rather than one per row and one misconfiguration is not multiplied by the principal count. - -It fires only on the phantom anchor, never on an ordinary owner-less row: the discrimination is `hasPhantomOwnerAnchor` provenance (is this `owner_id` the platform's injected constant, or a column the author declared?), not `owner == null` and not an `external` test. A federated object with a real declared remote owner column keeps scoping normally and stays silent. - -The diagnostic cannot cost a write — it returns `void`, its caller ignores it, and a throwing logger is swallowed, so no ordering of schema lookup, latch and logger can move a verdict. - -Also corrected in passing: this package attributed the driver's non-throwing unknown-column recovery to **SQLite specifically**. That understated it — the projection rung is gated by the driver's single shared `isUnresolvableColumnError` predicate, which spells all three dialects it speaks (`no such column`, `column … does not exist`, and since #8926 `Unknown column '…'`), so the silent refusal reproduced on every supported dialect. Wording only; no driver change. diff --git a/.changeset/platform-default-permission-sets-platform-owned.md b/.changeset/platform-default-permission-sets-platform-owned.md deleted file mode 100644 index d1e90d59b5..0000000000 --- a/.changeset/platform-default-permission-sets-platform-owned.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -fix(security): platform default permission sets are stamped `managed_by: 'platform'`, so `os meta resync` stops skipping every one of them (#8692) - - - -`bootstrapPlatformAdmin` seeded the default permission sets -(`admin_full_access` / `member_default` / `viewer_readonly` …) **without writing -`managed_by`**, so the value fell to the declared `defaultValue: 'admin'` on -`sys_permission_set`. `os meta resync` only reconciles rows the platform still -owns (`managed_by` absent or `'platform'`), so the platform's own default sets -took the skip branch — **measured on a real engine: `resynced 0` / -`resyncSkipped 8`, every shipped set**, each one logged as an *"intentional -override"* for a row no admin had ever touched. - -That is the exact inverse of what the resync flag was built for (#2705: -*"reconcile the row to the shipped dist so a dev source edit takes effect -without `--fresh`"*). The command could not perform, for the rows it names in -its own help text, the one job it exists to do. - -**The seed insert now stamps `managed_by: 'platform'` explicitly**, which also -puts this seeder in line with its two siblings in the same package — -`bootstrap-builtin-positions.ts` and `bootstrap-system-capabilities.ts` both -stamp `'platform'` rather than inheriting a default. A fresh install's default -sets are now platform-owned, and a resync reconciles all of them. Admin-takeover -protection is unchanged in shape and becomes *real* rather than nominal: a set -an admin takes over in Setup is stamped `'admin'` by the projection path, so -platform-seeded and admin-authored rows finally carry **different** values -instead of the same one. - -**Forward-stamp only — existing rows are deliberately NOT migrated.** A stored -`'admin'` is indistinguishable between "the old seeder's field default" and "an -administrator took this set over in Setup". Restamping legacy rows to -`'platform'` would make genuine admin customizations reconcilable and could -silently overwrite them on the next `os meta resync`, so pre-existing rows keep -the skip permanently and by decision. Report, don't rewrite. A legacy install -that wants its platform defaults reconciled has to re-own the rows deliberately -(or re-seed with `--fresh`) — an operator's choice, not one a boot makes for -them. The seeder's docblock records this so the next reader finds a decision -rather than a mystery. - -**The skip warning stops claiming intent.** It read -`… row is admin-owned (intentional override)`; on any pre-existing install that -sentence is false, because the only writer may have been this same seeder one -call earlier. It now reads `… row is admin-owned` — provenance and action, no -claim about anybody's intent. - -Two comments asserting that the insert-once posture *"keeps the platform -defaults env-authored — the posture `bootstrapDeclaredPermissions` relies on"* -are removed: that reliance was measured false. `bootstrapDeclaredPermissions` -special-cases only `managed_by === 'package'`; every other value — `'platform'` -included — falls to the same `skippedEnvAuthored` branch, so its behaviour is -identical before and after this change. - -The pin suite added by the measurement round now asserts both sides of the line -the ruling drew: a fresh install stores `'platform'` and resyncs everything, and -a pre-ruling `'admin'` row is still skipped with its content intact. 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/postgres-dsn-bound-secret-reaches-server.md b/.changeset/postgres-dsn-bound-secret-reaches-server.md deleted file mode 100644 index bebd80a09f..0000000000 --- a/.changeset/postgres-dsn-bound-secret-reaches-server.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -"@objectstack/service-datasource": patch ---- - -fix(security): a bound `external.credentialsRef` reaches the postgres SERVER on the DSN branch, not just the knex config (#8873) - -A postgres datasource whose `config.url` is a DSN and whose credential is bound -through `external.credentialsRef` (or the connection form's secret field) opened -its connection **with no password at all**. Not a disclosure — a broken binding, -of the fail-quietly kind: `DatasourceConnectionService` resolved the secret -fail-closed, the operator saw a bound credential and a datasource reporting -connected, and the handshake carried nothing. - -**This arm was the one that looked correct.** It had an explicit secret branch -and a comment declaring the intent — *"For a DSN, a separately-supplied secret -overrides the embedded password"* — and it emitted -`{ connectionString: url, password: spec.secret }`, which passes any assertion -written against the factory's own output. `pg` discarded the credential one -layer lower: - -```js -// pg 8.22.0, lib/connection-parameters.js -if (config.connectionString) { - config = Object.assign({}, config, parse(config.connectionString)) -} -``` - -Two independent mechanisms destroyed it, either sufficient on its own. `parse()` -emits a `password` key for **every** url — `''` when the url carries no userinfo -password — and `Object.assign` copies that over the injected value, after which -`val('password', …)` falls through to `PGPASSWORD` and the defaults; and knex's -`setHiddenProperty` has already made `password` a non-enumerable own property of -`connectionSettings`, which `Object.assign` does not copy at all. Measured on pg -8.22.0 + knex 3.3.0: `postgresql://app@db.internal:5432/app` with a secret bound -resolved to password `null`, and a stored pre-#8082 url embedding -`app:embedded-legacy@` resolved to `'embedded-legacy'` — the DSN beating the -credential an operator deliberately bound. Since #8082 refuses a -`user:password@` userinfo at the publish door, the credential-free DSN is the -only authorable URL shape for this driver, so this was the shape the connection -form produces. - -**The remedy is a third shape, not either sibling's.** The clients merge a DSN -against explicit keys in opposite directions: `mysql2` lets the explicit key win -(`{ uri, password }`, #8875) and mongodb rides in `options.auth` beside an -untouched url (#9042), while `pg` lets the DSN win. So on the postgres DSN -branch — and only when a secret is bound — `connectionString` is gone: the arm -hands `pg` **pg's own parse of the url** (`pg-connection-string`, the client's -parser, so there is no second dialect of `postgresql://…` in this repo to drift -out of agreement) with the credential applied afterwards, where nothing -re-parses over it. Everything else resolves exactly as before, verified -key-by-key across the sslmode, unix-socket, `?options=`, credential-free, -embedded-password and no-userinfo forms. - -The competing remedy — keep `connectionString` and splice the secret into the -userinfo — was measured and rejected on two counts: `pg-connection-string` -honours a `?password=` query parameter **over** userinfo, so a stored pre-#8337 -row would still lose the bound secret; and it would materialise the cleartext -credential into a string nothing hides (`JSON.stringify` of knex's -`connectionSettings` prints the whole DSN, while a discrete `password` stays -hidden), re-creating at connect time the hardest-to-redact credential spelling -that #8082 refuses to let anyone author. - -**What changes for an existing deployment.** A DSN datasource that binds no -secret is byte-for-byte unaffected — it still hands `pg` the url unparsed. One -behaviour worth knowing: a stored pre-#8082 row that embeds a password in its -url *and* binds a credential now authenticates with the **bound** credential, -which is the precedence this arm's own comment always claimed and both sibling -arms already apply. A DSN naming no user still receives the credential (unlike -the mongodb arm's deliberate no-op there): `pg` sends a password only when the -server asks for one, so injecting cannot break a datasource that connects today. -Finally, a url `pg`'s own parser rejects (a multi-host DSN, which node-postgres -does not implement) is now refused when the driver is built rather than on first -query — the same error, named and located, with the url deliberately not echoed -because it may itself embed a credential. 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/publish-refuses-non-canonical-stored-type.md b/.changeset/publish-refuses-non-canonical-stored-type.md deleted file mode 100644 index e82f00878c..0000000000 --- a/.changeset/publish-refuses-non-canonical-stored-type.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch -"@objectstack/metadata-core": patch -"@objectstack/spec": patch ---- - -fix(metadata): a package publish refuses a draft stored under a non-canonical metadata type, and the ADR-0010 audit writer asserts its `type` instead of folding it (#8908) - - - -**Two tightenings, one card, because they are the same defect at two layers.** - -`publishPackageDrafts` reads `sys_metadata` rows **at rest**, so #7894's `/meta` -boundary fold never reached it. `promoteDraftForPublish` folds the stored -spelling through `PLURAL_TO_SINGULAR` — the *manifest-collection* map, which -legitimately omits types that are not stack collections. For those the fold is a -**no-op**: the lookup key equals the stored spelling, the draft resolves, and the -publish mints an ACTIVE row in the namespace `PUT /meta/field/…` answers -403 NOT_OVERRIDABLE for. Measured on the card with the real repository over a -stub engine: - -``` -publishPackageDrafts({ packageId: 'app.demo' }) - → { success: true, publishedCount: 1, published: [{ type: 'fields', name: 'legacy_field' }] } -active row: { type: 'fields', name: 'legacy_field', package_id: 'app.demo' } -audit row: { type: 'fields', name: 'legacy_field', outcome: 'allowed', code: 'ok' } -``` - -Every registry read and every compliance query on `field` misses an item the -platform just reported as published — the #4432 shadowing shape, minted at -publish time instead of at the URL, and the last route by which a pre-#7894 row -could be re-promoted rather than migrated. - -**1. The publish refuses it, at the pre-flight, batch-atomically.** Same shape as -the ADR-0028 namespace-prefix gate that already stands there: found before -anything is promoted, failing the whole batch (`publishedCount: 0`, -`published: []`) rather than publishing the healthy siblings around it, with one -audit row per violation. The refusal names the row, names the canonical type, and -states the re-author path; `failed[].code` is the new -`STORED_TYPE_NOT_CANONICAL`, and the audit column's spelling is -`stored_type_not_canonical`. - -The rule is **derived, not a list**: a spelling the platform's URL/registry map -folds elsewhere *and* the manifest map leaves unchanged. Against the real maps -that is **six** spellings — `fields`, `seeds`, `external_catalogs`, -`externalCatalogs`, `translations`, `email_templates` — where the card named -four; the last two would have been missing from any hand-written list, and a -newly declared type that never reaches the manifest map is covered on the day it -is declared. A manifest-**present** plural (`objects`) is deliberately *not* in -the class: it is already fail-closed at the promote (`NO_DRAFT`, batch aborted) -and keeps that verdict. - -⛔ Deliberately **not** included: migrating the row (a `_migrate-stored` / -boot-reconciliation conversion). That was the other option on the card and is -explicitly unruled — it stays available as a follow-up with its own appetite. - -**2. `recordMetadataAudit` refuses a non-canonical `type` (`AUDIT_TYPE_NOT_CANONICAL`) -instead of folding it.** The writer used to open with -`type: PLURAL_TO_SINGULAR[entry.type] ?? entry.type` — a lenient consumer, and a -**tolerant-and-incomplete** one: the fold read the same manifest map, so the -compliance trail came out canonical for the 29 types that never needed it and -non-canonical for exactly the ones that did. Ruled the same direction as the -refusal above: **fold at the boundary, assert at the writer.** Every call site -that builds a row out of an at-rest `type` — all of them on -`publishPackageDrafts` — now folds with `canonicalMetaType`; the `/meta` routes -were already canonical by the time they got there. The throw sits **outside** the -writer's best-effort `try`, because inside it the method's own `catch` would -degrade the assert into a `console.warn`. - -The assert cannot refuse a canonical type (no canonical spelling folds -elsewhere — 33 of 33, measured) nor a plugin-registered or otherwise -unrecognised kind (`canonicalMetaType` is the identity for anything the static -map does not carry), so it narrows the accept set without closing it. - -**Reachability was enumerated before the assert landed**, as the ruling required: -`recordMetadataAudit` is private to `protocol.ts` with 11 call sites, `sys_metadata` -rows have exactly one producer in the repository (`saveMetaItem` → `repo.put`, -post-fold), and no current write path can mint a non-canonical stored type. The -only non-canonical types that ever reached an audit write came from the batch -publish's at-rest rows, which is what the boundary folds now cover. - -Also fixed, as a consequence of that fold rather than as a separate change: on -the batch route `getEffectiveLock`'s overlay limb was queried with the raw stored -spelling, so an ADR-0010 `_lock` carried by the canonical active row was looked -up under a `type` no row has and came back `'none'` — the verdict "the author -declared no protection". That is the batch twin of the hole #8769 closed on -`publishMetaItem`. diff --git a/.changeset/read-seam-empty-accumulator-discrimination.md b/.changeset/read-seam-empty-accumulator-discrimination.md deleted file mode 100644 index d421a924d6..0000000000 --- a/.changeset/read-seam-empty-accumulator-discrimination.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): four read seams that FAILED no longer answer out of an empty accumulator — only an unprovisioned table is read as truthful emptiness (#8896) - -Four reads in `@objectstack/metadata-protocol` sat behind a bare `catch` that -fell through — or, in one case, jumped — above a value the read was supposed to -fill. Each handed its caller an answer indistinguishable from a legitimate one, -with nothing logged and no field saying the answer was incomplete. Per ADR-0110 -D3 those are different facts, and at every one of these seams they have opposite -consequences: - -- **`SeedLoaderService.loadExistingRecords()`** returned an empty `Map`. That map - is not a cache — it IS the write decision, in all three of its callers, and - "empty" means *write these rows*: the upsert pre-load turns every update into - an INSERT, and `bulkWrite`'s `attempt > 1` recheck — the only thing standing - between an at-least-once retry and a duplicate of every row the first attempt - already committed (framework#3149) — is silently disarmed. -- **`searchAll()`** skipped the object on a per-object `catch { continue; }` - while the response still reported `totalObjects` / `totalHits` / `truncated` - as though the sweep had been complete: a partial scan wearing a whole one's - numbers. -- **`findReferencesToMeta()`** dropped a whole source type on a per-matcher - `catch { return; }`. That list answers "what would break if I delete this" and - is rendered as the admin UI's "Used by" panel, so a silently short list reads - as "nothing depends on it — safe to remove". -- **`publishPackageDrafts()`** did not fall through: it pushed a **fabricated** - ADR-0067 revert-plan entry, `{ existedBefore: false, prevVersion: null }` — - the literal opposite of the healthy branch's `existedBefore: !!activeRow`. - `existedBefore: false` means "revert = soft-remove", so reverting that commit - DELETES an artifact whose previous version was supposed to be restored. - -None of the four `catch`es is removed; each is **discriminated by error type**, -through the same shared `isMissingTableError` predicate -(`@objectstack/metadata/errors`) that `DatabaseLoader`, `SysMetadataRepository` -and `cascadeDeleteRelations` already use: - -- **benign, unchanged** — the table was never provisioned (schema sync not run - yet). It can hold no rows, so the empty answer is the truth and each seam - behaves exactly as before: the seed writes its rows, the search skips the - object, the publish records `existedBefore: false`. -- **everything else now surfaces** — a connection drop, a timeout, a permission - denial, a query error, a missing column on a provisioned table. The caller - receives the read's own failure, envelope intact. - -`findReferencesToMeta` is the one seam that gets no predicate of its own: it -reads through `getMetaItems`, which already performs exactly this discrimination -(`rethrowUnlessMetadataStoreUnprovisioned`, #5532) and raises a 503 -`SERVICE_UNAVAILABLE` for a real outage. The only thing its `catch` could -swallow was that deliberate 503, so it is simply gone. - -No new error code and no new response field. The behavioural change is that a -seed load, a global search, a reference scan or a package publish which used to -report success over an unreadable store now reports the failure that made it -unreadable. `publishPackageDrafts` refuses before Phase 1's transaction, so a -refused publish leaves the draft pending and writes nothing. - -The comment above the publish capture claimed a capture failure "just omits that -item from the revert plan". That was wrong twice — the code fabricated rather -than omitted, and omitting would have left the item unreverted while reporting -the turn undone — and it now describes what the code does. 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/reference-tables-default-bearing-optional.md b/.changeset/reference-tables-default-bearing-optional.md deleted file mode 100644 index 8bef5e6229..0000000000 --- a/.changeset/reference-tables-default-bearing-optional.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): reference tables stop marking `.default()`-bearing members as required, and name the default instead (#8703) - -The Required column of every `content/docs/references/**` property table mirrored -the emitted JSON Schema's `required` array. `build-schemas.ts` emits the -**output** (post-parse) shape for 1458 of the 1582 published documents, falling -back to the **input** shape only when output emission throws — and in an output -shape a `.default()`-bearing member is listed in `required`, because the parse -always produces it. So the column answered "must I write this?" with `✅` for -keys the author may freely omit. - -**Measured on the emitted tree: 2526 property occurrences across 529 documents** -were in `required` while carrying a `default`. `kernel/metadata-plugin.mdx` is -the specimen the card was filed on — `enableEvents`, `validateOnWrite`, -`enableVersioning`, `cacheMaxItems` and `bootstrap` all read `✅`, and all five -are omittable. - -Two consequences, both fixed here: - -- Reference tables are read far more often by an AI author than by a human - (ADR-0033), and omitting optional keys is that author's normal mode. A wall of - `✅` teaches over-specification, and buries the genuinely-required keys among - the ones that are not. -- The same member rendered `✅` on an output-shape page and `optional` on one of - the 124 input-shape pages, so a refactor that merely flipped a def between the - two emission modes rewrote its whole Required column with no semantic change to - what an author writes. - -**The fix reads `default` rather than `required`**: a property carrying a -`default` is author-omittable by construction in *both* emission modes, so it now -renders `optional (default: \`false\`)` — strictly more information than either -previous cell, since the value an author gets by omitting the key was nowhere on -the page before. A structural default too wide for the cell renders -`optional (has default)` (13 cells; the budget's discontinuity is documented at -`INLINE_DEFAULT_WIDTH_LIMIT`), and a property with no default is untouched in -both directions. - -**The JSON Schemas are deliberately unchanged.** `build-schemas.ts` is not -touched by this fix: the emitted artifacts keep describing the post-parse shape -and keep validating post-parse data. Only the doc renderer reads the author's -question differently. 146 reference pages are regenerated. 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/scaffold-runtime-image-pinned.md b/.changeset/scaffold-runtime-image-pinned.md deleted file mode 100644 index 822ba6b863..0000000000 --- a/.changeset/scaffold-runtime-image-pinned.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -"create-objectstack": patch ---- - -fix(create-objectstack): the scaffolded Dockerfile pins the runtime image to the CLI that builds the artifact, instead of `latest` under a comment saying to pin (#9017) - -`src/templates/blank/Dockerfile` shipped `FROM ghcr.io/objectstack-ai/objectstack:latest` -directly beneath a comment instructing the reader to "pin the tag to the -`@objectstack/cli` version in your package.json so the runtime matches the CLI that built -the artifact" — an instruction the scaffold itself did not follow. Every app made with -`npx create-objectstack` shipped that contradiction from day one, and `docker/README.md`'s -tag table already scopes `latest` to quick starts while documenting `X.Y.Z` as the -production pin. - -Measured on scaffolded output rather than the template's bytes, before the fix: - -``` -emitted package.json cli range : ^17.0.0 -emitted Dockerfile FROM : FROM ghcr.io/objectstack-ai/objectstack:latest -agreement (tag vs cli range) : DISAGREE -``` - -**The tag is resolved after `install`, from the installed CLI — not from the generated -`package.json`.** That file carries a caret RANGE, and the two are not interchangeable: -npm resolves `^17.0.0` to the newest 17.x, so pinning the range's floor would ship a -runtime image *older* than the CLI that built the artifact — breaking the same promise in -a new way. The rolling `:17` tag does match the range's float window but is exactly what -the tag table tells production not to use. The resolved version is the only value that -makes the sentence true, and it is the rule the repo already applies for this purpose in -`.github/workflows/scaffold-e2e.yml` ("Pin the runtime's CLI to the SAME version the -generated project actually resolved to — NOT a hardcoded `latest`"). - -**Both halves move together.** Pinning the line while leaving an imperative to pin by hand -would relocate the contradiction rather than remove it, so the comment above the `FROM` -line is replaced in the same rewrite. With `--skip-install` there is no resolved version: -the tag stays `latest` and the comment keeps telling the reader to pin — which is true on -that path, because there the user really must do it by hand. - -The regression proof asserts on **scaffolded output**, never on the template: it scaffolds -with the real copy/sync/pin path, plants an installed CLI whose version is deliberately -*not* the range's floor (the normal case, and the one that a package.json-derived tag -would get wrong), and checks the emitted `FROM` tag against the emitted `package.json` -range with a satisfies-check rather than equality. - -`.github/workflows/scaffold-e2e.yml` now reads the tag it builds its local runtime image -under **out of the generated Dockerfile** instead of hardcoding `:latest`. Those were two -hand-matched literals; had they skewed, Docker would have quietly pulled the last -published image instead of the one built from this checkout, and the job's own stated -hermeticity would have been false while it stayed green. 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/silly-pandas-repeat.md b/.changeset/silly-pandas-repeat.md deleted file mode 100644 index f5b3ffba8c..0000000000 --- a/.changeset/silly-pandas-repeat.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -Three write-surface lint rules now ask provenance, not just membership, before exempting a system column (#8663). - -`validate-hook-body-writes`, `validate-action-body-writes` and `validate-flow-node-writes` share one `IMPLICIT_FIELDS` set, which is object-INDEPENDENT: it answers "could this name be implicitly writable somewhere", never "did the platform provision a column for it on THIS object". On an ADR-0015 `external` object those diverge — the registry injects `owner_id` / `organization_id` / the audit family onto a federated object exactly as onto a local one, but the remote database owns the schema and no column exists behind them. - -Each rule now emits a new advisory finding on that path instead of staying silent — `hook-body-write-unprovisioned-anchor`, `action-body-write-unprovisioned-anchor`, `flow-node-write-unprovisioned-anchor` — sharing the `unprovisionedAnchorCause` / `unprovisionedAnchorHint` wording the read-axis rules already use. All three are `warning`: the flow-node rule's existence finding still gates at `error`, and its provenance finding deliberately does not, because the claim is about a remote schema this repo cannot see. - -An author-DECLARED column of the same name is untouched — on a federated object it maps a remote column the author vouches for. `FlowNodeWriteSeverity` widens from `'error'` to `'error' | 'warning'` accordingly. diff --git a/.changeset/spec-unresolvable-column-mysql-reach-addendum.md b/.changeset/spec-unresolvable-column-mysql-reach-addendum.md deleted file mode 100644 index 97e110bc25..0000000000 --- a/.changeset/spec-unresolvable-column-mysql-reach-addendum.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): the `driver-sql-unresolvable-where-column-refused` ledger entry states MySQL's reach as it is after #8926, not as it was at registration (#9060) - -Text amendment to an already-registered ADR-0087 entry — the entry id, `surface` -and `replacement` prescription are unchanged, and no accept/reject behaviour -moves. What changes is the `reason`, which is upgrader-facing documentation: it -is the data source for `objectstack migrate meta`, `spec-changes.json` and the -generated upgrade guide. - -The entry's "Reach, stated rather than assumed" paragraph said MySQL was outside -the refusal — true when #8790 registered it, false the moment #8926 merged (PR -#9061). A MySQL user reading "on MySQL this condition still travels out as the -raw dialect error" would have concluded the migration did not apply to them, -which is exactly wrong after parity. - -The historical paragraph is kept verbatim as the state at registration, and a -dated addendum states both halves of what the one shared predicate did on MySQL: - -- **The envelope** — an unresolvable WHERE column refuses with the same - `INVALID_FILTER` / 400 naming the column, instead of the raw - `ER_BAD_FIELD_ERROR` with the statement's bound literals inlined. -- **The recoveries** — MySQL also gained the #3821 projection and ORDER-BY - recoveries it never had, so those positions now return recovered rows where - they used to throw. - -Both arrive together because `ER_BAD_FIELD_ERROR` spells every clause position -with one sentence, so all three ride one arm of the predicate — pinned as the -ruled direction by the widened sweep in -`sql-driver-unresolvable-where-column-refusal.test.ts`. Unchanged by that -ruling, and said so in the addendum: a dotted filter key is still classified per -dialect, the axis #8371 owns. 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-comment-moderation-delete-policy.md b/.changeset/sys-comment-moderation-delete-policy.md deleted file mode 100644 index 6531a694db..0000000000 --- a/.changeset/sys-comment-moderation-delete-policy.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -fix(security): comment moderation stops being dead behind the platform delete floor — `sys_comment` gets the per-object delete policy that lets a parent-record editor moderate (#8839) - - - -`plugin-audit` implements an explicit **author-or-parent-editor** rule for -removing a comment — *"Rewriting or removing someone else's words is moderation, -hence the tighter author-or-parent-editor rule"* — deriving a comment's access -from the record its `thread_id` names, the way an attachment's derives from its -parent. - -**That rule was unreachable in every org-bound deployment.** `member_default` -ships a wildcard row-level delete floor: - -``` -{ name: 'owner_only_deletes', object: '*', operation: 'delete', - using: 'created_by == current_user.id', positions: ['org_member'] } -``` - -A parent-record editor moderating someone else's comment holds `org_member` and -is not the comment's `created_by`, so the floor answered `PERMISSION_DENIED` -before the moderation rule was ever consulted. The floor is a **second, -parent-blind implementation** of "who may remove this row", and on `sys_comment` -it was winning against the one authority that can actually see the parent. - -**Why nothing caught it:** the only fixture proving the capability -(`comments-permission-matrix.dogfood.test.ts` case (d)) booted **org-less**, so -its principals resolved `positions: ['everyone']`, the positions-gated floor never -applied, and the case passed over the broken behaviour — #8023's disarm shape. - -**The fix is one per-object policy** in `member_default`: - -``` -{ name: 'sys_comment_moderation', object: 'sys_comment', operation: 'delete', - using: 'id != null', positions: ['org_member'] } -``` - -It contributes the **alternate match** that stops the floor pre-empting the gate; -it does not re-implement the rule. The parent-editor limb is not expressible as a -row predicate — the authority lives on another record and RLS has no join — so -`id != null` is every row of this object said plainly, the same spelling and -reasoning as the existing `sys_invitation_org_admin`. What actually narrows a -`sys_comment` delete is, in order: the object-level delete bit (this set grants no -`allowDelete` at all), Layer 0's tenant wall, and then plugin-audit's gate, which -requires every matched row to pass and fails closed on a thread naming no -authorizable parent. That gate is not optional — `AuditPlugin` registers -`sys_comment` and installs the gate in the same `start()`. - -⛔ **The wildcard floor itself is unchanged.** The widening is scoped to -`sys_comment`, and to the `delete` limb only; the `update` half of plugin-audit's -rule deliberately stays under the floor. - -The `positions: ['org_member']` domain is load-bearing rather than cosmetic: it -confines the widening to exactly the principals the floor binds. An undomained -twin would carry a `using` into a delete class that is **empty** today for -org-less and `everyone`-only principals, switching off the derive-from-select rule -that currently bounds their writes to their readable set — widening them too. - -Access-widening approved by maintainer ruling (2026-08-15), which is what the -standing manual floor on relaxing an access-control boundary required. - -The pin is the fixture, now **armed**: `orgContext: true` plus `assertArmed` on -both the author and the moderator persona, so the file can never again certify -moderation from a boot structurally unable to observe the floor. Reverse-verified -— with the policy removed and the artifact rebuilt, exactly one case reddens with -`PERMISSION_DENIED` on `sys_comment` and the other nine stay green. The -stranger-without-parent-EDIT case now asserts its refusal code **exactly** -(`RECORD_NOT_ACCESSIBLE`, plugin-audit's gate — not the floor's -`PERMISSION_DENIED`), so the floor silently re-asserting itself over `sys_comment` -cannot pass as a correct refusal. 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/tidy-pugs-shave.md b/.changeset/tidy-pugs-shave.md deleted file mode 100644 index 06a36a21db..0000000000 --- a/.changeset/tidy-pugs-shave.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Remove the four dead `'objects'` spelling tolerances in the metadata protocol's object registry and storage seams. - -`applyObjectRegistryMutation`, `applyRegistryWriteThrough`, `ensureObjectStorage` and `dropObjectStorage` each admitted a plural `'objects'` type key, and the first of them *registered under it* — the spelling-tolerant-lookup shape `canonicalMetaType`'s header rejects, and the one that previously let a plural registry entry shadow an entire code-authored listing. - -All four are unreachable: every producer folds the type through `PLURAL_TO_SINGULAR` / `canonicalMetaType` before these seams see it. No behaviour changes for any caller that folds — which is all of them. What changes is the failure mode of a future caller that does *not* fold: it no longer silently registers an object under a plural key, so `assertObjectRegistered` fails closed with a loud, recoverable error instead. - -Folding at the producer remains the rule; these guards were never a second line of defence. 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/url-userinfo-username-accessor.md b/.changeset/url-userinfo-username-accessor.md deleted file mode 100644 index 46690f047c..0000000000 --- a/.changeset/url-userinfo-username-accessor.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): export `urlUserinfoUsername` — the username half of the shared URL userinfo grammar (#8876) - -`@objectstack/spec/data` owns the DSN userinfo grammar (`urlUserinfoPassword` / -`redactUrlPassword`, #8082/#8300) but exported only its password half. The -mongo DSN arm (#8696) must inject a bound `external.credentialsRef` secret via -`MongoClient`'s `auth` option, which requires the username the URL already -names — and reading it needs this grammar, because `new URL()` throws -`ERR_INVALID_URL` on the multi-host DSN form `MongoConfigSchema` documents -(`mongodb://app@h1:27017,h2:27017/app`, measured). A local copy in -`service-datasource` is the shape the #8082 single-parse ruling refuses by -name. - -**Additive only.** The new accessor shares the password half's boundary parse -by construction (both now call one internal RFC-3986 userinfo parse), returns -the RAW component (percent-encoding preserved, decoding stays with the -caller), answers `''` for an empty username inside present userinfo and -`undefined` when the string carries no userinfo at all, and still parses the -publish-refused `user:password@` shape correctly — stored legacy rows carry -it, and #8155's migration path must judge exactly those rows. No Zod schema -changes: every input that validated before validates identically after; the -read-path redaction alignment pin now covers the username half too (redaction -preserves the username byte-for-byte). 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/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index 1993d7b148..08994aedcb 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -73,7 +73,7 @@ docker run -p 8080:8080 \ -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ -e OS_AUTH_SECRET \ -e OS_SECRET_KEY \ - ghcr.io/objectstack-ai/objectstack:17.0.0 + ghcr.io/objectstack-ai/objectstack:17.1.0 ``` (`OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come @@ -91,7 +91,7 @@ docker run -p 8080:8080 \ -e OS_ARTIFACT_URL="https://releases.example.com/hotcrm-2.2.2.json#sha256=<64 hex chars>" \ -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ -e OS_AUTH_SECRET -e OS_SECRET_KEY \ - ghcr.io/objectstack-ai/objectstack:17.0.0 + ghcr.io/objectstack-ai/objectstack:17.1.0 ``` Both schemes work: `https://…` is fetched at boot, `file:///…` is read directly @@ -142,7 +142,7 @@ COPY . . RUN npx os build # → dist/objectstack.json # ── Runtime: the official ObjectStack runtime image ────────────────── -FROM ghcr.io/objectstack-ai/objectstack:17.0.0 +FROM ghcr.io/objectstack-ai/objectstack:17.1.0 COPY --from=build --chown=node:node /app/dist/objectstack.json /srv/app/objectstack.json ``` @@ -160,7 +160,7 @@ image)? The official image is nothing more than: ```dockerfile title="Dockerfile (self-built runtime, equivalent)" FROM node:22-slim -RUN npm install -g @objectstack/cli@17.0.0 +RUN npm install -g @objectstack/cli@17.1.0 WORKDIR /srv/app RUN chown node:node /srv/app diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index 71b4db4c36..0443d7f540 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -38,7 +38,7 @@ version in production** and move it deliberately: ```bash # docker-compose.yml, or your orchestrator's manifest -image: ghcr.io/objectstack-ai/objectstack:17.0.0 +image: ghcr.io/objectstack-ai/objectstack:17.1.0 ``` On a host running the artifact directly under systemd, the same move is a file diff --git a/docker/README.md b/docker/README.md index ed61a66a9b..f1855f4de2 100644 --- a/docker/README.md +++ b/docker/README.md @@ -29,7 +29,7 @@ Multi-arch: `linux/amd64` + `linux/arm64`. [Self-Hosted Deployment](https://objectstack.ai/docs/deployment/self-hosting)): ```dockerfile -FROM ghcr.io/objectstack-ai/objectstack:17.0.0 +FROM ghcr.io/objectstack-ai/objectstack:17.1.0 COPY --chown=node:node dist/objectstack.json /srv/app/objectstack.json ``` @@ -40,7 +40,7 @@ docker run -p 8080:8080 \ -v "$PWD/dist/objectstack.json:/srv/app/objectstack.json:ro" \ -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ -e OS_AUTH_SECRET -e OS_SECRET_KEY \ - ghcr.io/objectstack-ai/objectstack:17.0.0 + ghcr.io/objectstack-ai/objectstack:17.1.0 ``` `OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come @@ -62,5 +62,5 @@ reverse-proxy / multi-node guidance: ## Local build of this image ```bash -docker build -t objectstack:dev --build-arg OS_CLI_VERSION=17.0.0 docker/ +docker build -t objectstack:dev --build-arg OS_CLI_VERSION=17.1.0 docker/ ``` diff --git a/examples/app-crm/CHANGELOG.md b/examples/app-crm/CHANGELOG.md index bdd2ec03a7..fffa52bf95 100644 --- a/examples/app-crm/CHANGELOG.md +++ b/examples/app-crm/CHANGELOG.md @@ -1,5 +1,52 @@ # @objectstack/example-crm +## 4.0.93 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [3d61924] +- Updated dependencies [716ac9b] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [20067c5] +- Updated dependencies [e783e16] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [4fc4a3c] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [7fc01db] +- Updated dependencies [19db5fa] +- Updated dependencies [2b9d33a] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..455386a129 100644 --- a/examples/app-showcase/CHANGELOG.md +++ b/examples/app-showcase/CHANGELOG.md @@ -1,5 +1,72 @@ # @objectstack/example-showcase +## 0.3.15 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [2277443] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [3508678] +- Updated dependencies [d491625] +- Updated dependencies [3d61924] +- Updated dependencies [9c4d096] +- Updated dependencies [716ac9b] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [e0695b5] +- 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 [90a12fb] +- Updated dependencies [72050cc] +- Updated dependencies [d70428a] +- Updated dependencies [c8806ae] +- Updated dependencies [bb96297] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [0961065] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [7fc01db] +- Updated dependencies [19db5fa] +- Updated dependencies [2b9d33a] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [a4acb8d] +- Updated dependencies [d634e66] + - @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..c14c3adb8f 100644 --- a/examples/app-todo/CHANGELOG.md +++ b/examples/app-todo/CHANGELOG.md @@ -1,5 +1,67 @@ # @objectstack/example-todo +## 4.0.93 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [a751f7d] +- Updated dependencies [caaae2c] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [3d61924] +- Updated dependencies [716ac9b] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [4e71ae1] +- Updated dependencies [20067c5] +- Updated dependencies [e783e16] +- Updated dependencies [ff4ba6a] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [4fc4a3c] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [7fc01db] +- Updated dependencies [19db5fa] +- Updated dependencies [2b9d33a] +- Updated dependencies [8bee54b] +- 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 [d634e66] + - @objectstack/spec@17.1.0 + - @objectstack/runtime@17.1.0 + - @objectstack/objectql@17.1.0 + - @objectstack/client@17.1.0 + - @objectstack/mcp@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..a96dececdf 100644 --- a/examples/embed-objectql/CHANGELOG.md +++ b/examples/embed-objectql/CHANGELOG.md @@ -1,5 +1,51 @@ # @objectstack/example-embed-objectql +## 0.0.33 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [a751f7d] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [4e71ae1] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- 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 [d634e66] + - @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..af12d07e91 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,23 @@ # @objectstack/hono +## 17.1.0 + +### Patch Changes + +- Updated dependencies [e43d63a] +- Updated dependencies [3d61924] +- Updated dependencies [27a567d] +- 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..a34dccce04 100644 --- a/packages/apps/account/CHANGELOG.md +++ b/packages/apps/account/CHANGELOG.md @@ -1,5 +1,53 @@ # @objectstack/account +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- 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] +- Updated dependencies [d634e66] + - @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..b90b47619c 100644 --- a/packages/apps/setup/CHANGELOG.md +++ b/packages/apps/setup/CHANGELOG.md @@ -1,5 +1,53 @@ # @objectstack/setup +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- 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] +- Updated dependencies [d634e66] + - @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..1cafa45fdb 100644 --- a/packages/apps/studio/CHANGELOG.md +++ b/packages/apps/studio/CHANGELOG.md @@ -1,5 +1,53 @@ # @objectstack/studio +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- 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] +- Updated dependencies [d634e66] + - @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..e8090288b4 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,216 @@ # @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 [d6e80b2] +- 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 [a751f7d] +- Updated dependencies [cf0d902] +- Updated dependencies [498f4e8] +- Updated dependencies [cc5c07b] +- Updated dependencies [caaae2c] +- Updated dependencies [13d7864] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [3508678] +- Updated dependencies [d491625] +- Updated dependencies [04d03c3] +- Updated dependencies [8656d67] +- Updated dependencies [177442d] +- Updated dependencies [950bd94] +- Updated dependencies [3043e98] +- Updated dependencies [3d61924] +- Updated dependencies [9c4d096] +- Updated dependencies [716ac9b] +- Updated dependencies [7b3c033] +- Updated dependencies [6feac91] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [4ea921c] +- Updated dependencies [3ab2488] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [fd6bdf8] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [e0695b5] +- Updated dependencies [a9df51c] +- Updated dependencies [b705a6c] +- Updated dependencies [f8eb736] +- Updated dependencies [ab8b10f] +- Updated dependencies [4e71ae1] +- Updated dependencies [20067c5] +- Updated dependencies [d09d0fd] +- Updated dependencies [e783e16] +- Updated dependencies [ff4ba6a] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b537855] +- Updated dependencies [ead96d0] +- Updated dependencies [b69d0f5] +- Updated dependencies [4dc8a61] +- Updated dependencies [c15eb23] +- Updated dependencies [4d47afe] +- Updated dependencies [4fc4a3c] +- Updated dependencies [b740440] +- Updated dependencies [90a12fb] +- Updated dependencies [72050cc] +- Updated dependencies [d70428a] +- Updated dependencies [c8806ae] +- Updated dependencies [bb96297] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [e6e1de4] +- Updated dependencies [3851f87] +- Updated dependencies [c73eacd] +- Updated dependencies [f8537df] +- Updated dependencies [712e185] +- Updated dependencies [693c788] +- Updated dependencies [0961065] +- Updated dependencies [845e164] +- Updated dependencies [8d017eb] +- Updated dependencies [1a7f907] +- Updated dependencies [4e3a4c3] +- Updated dependencies [501ed0e] +- Updated dependencies [f047810] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [7fc01db] +- Updated dependencies [88ef34d] +- Updated dependencies [19db5fa] +- Updated dependencies [b849e69] +- Updated dependencies [add2d19] +- Updated dependencies [2b9d33a] +- Updated dependencies [b53d38e] +- Updated dependencies [192213f] +- Updated dependencies [8bee54b] +- Updated dependencies [04f8fdb] +- Updated dependencies [c25b2d5] +- 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 [d5156b9] +- 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 [d634e66] +- 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/objectql@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/types@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/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 +3372,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..105a1c563c 100644 --- a/packages/client-react/CHANGELOG.md +++ b/packages/client-react/CHANGELOG.md @@ -1,5 +1,51 @@ # @objectstack/client-react +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [caaae2c] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..1b3efa8050 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,61 @@ # @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 [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + ## 17.0.0 ### Major Changes @@ -207,7 +263,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..31e6348477 100644 --- a/packages/cloud-connection/CHANGELOG.md +++ b/packages/cloud-connection/CHANGELOG.md @@ -1,5 +1,207 @@ # @objectstack/cloud-connection +## 17.1.0 + +### Minor Changes + +- e0695b5: fix(cloud-connection): the four mutating `install-local` routes require the `manage_metadata` capability, and the `x-user-id` header fallback is gone (#8976) + + + + **BREAKING for any integration that installs, uninstalls, reseeds or purges a + local marketplace package with a principal holding no authoring capability — and + for anything that identified itself to these routes with an `x-user-id` header.** + Landing after the v17.0.0 cut, so it ships as `minor` under the lockstep + launch-window convention. + + `MarketplaceInstallLocalPlugin`'s `requireAuthenticatedUser` asked one question — + "is there a session?" — and it was the only check on all four mutating routes: + + - `POST /api/v1/marketplace/install-local` — accepts an **inline manifest**, + hot-registers its objects into the shared registry, runs `syncSchemas()` + against the shared database, writes the install ledger and runs seed data; + - `DELETE /api/v1/marketplace/install-local/:manifestId`; + - `POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data`; + - `POST /api/v1/marketplace/install-local/:manifestId/purge-sample-data`. + + It also ended in a fallback that trusted a bare **`x-user-id` request header**, + commented as being "for cases where auth is disabled (e.g. test stubs)". + + **Measured through the composed plugin, to the point the state actually changes** + — `manifest.register()`, `objectql.syncSchemas()`, the ledger file on disk, + `SeedLoaderService.load()`, `driver.delete()`. All three principal shapes were + indistinguishable, and every effect fired for every one of them: + + | principal | install | reseed | purge | uninstall | + | :-------------------------------------- | :------ | :------ | :------ | :-------- | + | bare `x-user-id` header, **no session** | **200** | **200** | **200** | **200** | + | authenticated, **no** `manage_metadata` | **200** | **200** | **200** | **200** | + | authenticated, `manage_metadata` | 200 | 200 | 200 | 200 | + + Nothing downstream refused any of it. The first row is the sharper half: with no + session store consulted first, a caller who could reach the port completed a + full schema-mutating install and had `installedBy` recorded as a string of their + own choosing. + + **Severity by deployment shape.** Metadata is environment-scoped rather than + org-scoped, so Layer 0's tenant wall does not reach these writes: on the walled + multi-org EE shape this is a cross-tenant write channel — any signed-up user of + any customer organization could mutate the schema every other tenant runs on, + and `organization_admin` deliberately withholds `manage_metadata` precisely + because a tenant administrator is not supposed to. It also nullified the + already-implemented cloud-side ruling that AI `build` be structurally closed on + that shape: closing the build agent while this route stayed open closed the + front door and left the loading dock unlocked. On a single-org self-host the + severity is genuinely lower — every user is one tenant's — but "any employee + with a login can alter the schema and run seed data" still contradicts the + operator-action framing, and the header fallback admitted callers with no login + at all. The measurements above are code-path measurements through a composed + host, not an exploit demonstrated against a running deployment. + + **The fix.** All four routes now resolve identity **and** capability through + `resolveAuthzContext` — the platform's single authorization resolver + (`@objectstack/core`) — and demand ADR-0066 D1's `manage_metadata`, the same key + the `/meta` write doors carry (#6603, and #8919 for the promotion verbs). A + caller with no resolvable principal gets `401 UNAUTHENTICATED`; an authenticated + caller without the capability gets `403 FORBIDDEN` naming the capability they + need. The refusal is issued before any work, so a refused caller cannot probe + what is installed through a downstream error. Service and operator tokens are + exempt exactly as elsewhere, with no special case: an API key resolves through + the same resolver to its owner's real grants. + + **The `x-user-id` fallback is removed, not mode-gated.** It carried no mode flag + to gate it to, and it was the last `x-user-id` trust left in `packages/**` + source — the two sibling raw-route surfaces that carried the identical line had + it _removed_ in favour of this same resolver rather than restricted + (`plugin-sharing`'s share-link routes, `service-settings`' settings routes). The + one first-party caller of these routes, `os package install`, signs in for a + real better-auth session cookie and never sent the header. + + The plugin's mount stays **unconditional** (cloud#1287 moved it out of the + `marketplaceUrl` ternary so air-gapped boxes stop 404ing). This is authorization + on the routes, not un-mounting the plugin. + + **Anti-drift.** `marketplace-install-local-capability-enumeration.test.ts` + derives the mutating routes from the plugin's own route table and compares them + against a declared list, so a new mutating install-local route fails the build + until it is enumerated and its refusal cases run. Each refusal asserts the + ADR-0112 envelope (`code` **and** `status`) _and_ that no registry, schema, + ledger, seed or delete effect fired — a gate that answers 403 after + `syncSchemas()` has run is still the bug. + + Two existing suites whose names read as authorization coverage — + `marketplace-install-local-posture-gate.test.ts` (the ADR-0120 D5e ceremony, + which the caller satisfies from their own request body) and + `marketplace-install-local-tenancy-posture.test.ts` (which selects a seeding + path) — now open with an explicit statement of what they do **not** cover and + name the file that does, backed by an assertion that the named file exists so + the correction cannot rot into a wrong answer. Neither test was weakened. + +### 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 [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [3d61924] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- 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 [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [7fc01db] +- Updated dependencies [19db5fa] +- Updated dependencies [2b9d33a] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] +- Updated dependencies [d634e66] + - @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..3724b853c5 100644 --- a/packages/connectors/connector-mcp/CHANGELOG.md +++ b/packages/connectors/connector-mcp/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/connector-mcp +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..eb33342116 100644 --- a/packages/connectors/connector-openapi/CHANGELOG.md +++ b/packages/connectors/connector-openapi/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/connector-openapi +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..32982e9bd3 100644 --- a/packages/connectors/connector-rest/CHANGELOG.md +++ b/packages/connectors/connector-rest/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/connector-rest +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..ba69cb7fdf 100644 --- a/packages/connectors/connector-slack/CHANGELOG.md +++ b/packages/connectors/connector-slack/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/connector-slack +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..818aca54ec 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,353 @@ # @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 [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..908da36aba 100644 --- a/packages/create-objectstack/CHANGELOG.md +++ b/packages/create-objectstack/CHANGELOG.md @@ -1,5 +1,77 @@ # 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. + +### Patch Changes + +- f2f09e4: fix(create-objectstack): the scaffolded Dockerfile pins the runtime image to the CLI that builds the artifact, instead of `latest` under a comment saying to pin (#9017) + + `src/templates/blank/Dockerfile` shipped `FROM ghcr.io/objectstack-ai/objectstack:latest` + directly beneath a comment instructing the reader to "pin the tag to the + `@objectstack/cli` version in your package.json so the runtime matches the CLI that built + the artifact" — an instruction the scaffold itself did not follow. Every app made with + `npx create-objectstack` shipped that contradiction from day one, and `docker/README.md`'s + tag table already scopes `latest` to quick starts while documenting `X.Y.Z` as the + production pin. + + Measured on scaffolded output rather than the template's bytes, before the fix: + + ``` + emitted package.json cli range : ^17.0.0 + emitted Dockerfile FROM : FROM ghcr.io/objectstack-ai/objectstack:latest + agreement (tag vs cli range) : DISAGREE + ``` + + **The tag is resolved after `install`, from the installed CLI — not from the generated + `package.json`.** That file carries a caret RANGE, and the two are not interchangeable: + npm resolves `^17.0.0` to the newest 17.x, so pinning the range's floor would ship a + runtime image _older_ than the CLI that built the artifact — breaking the same promise in + a new way. The rolling `:17` tag does match the range's float window but is exactly what + the tag table tells production not to use. The resolved version is the only value that + makes the sentence true, and it is the rule the repo already applies for this purpose in + `.github/workflows/scaffold-e2e.yml` ("Pin the runtime's CLI to the SAME version the + generated project actually resolved to — NOT a hardcoded `latest`"). + + **Both halves move together.** Pinning the line while leaving an imperative to pin by hand + would relocate the contradiction rather than remove it, so the comment above the `FROM` + line is replaced in the same rewrite. With `--skip-install` there is no resolved version: + the tag stays `latest` and the comment keeps telling the reader to pin — which is true on + that path, because there the user really must do it by hand. + + The regression proof asserts on **scaffolded output**, never on the template: it scaffolds + with the real copy/sync/pin path, plants an installed CLI whose version is deliberately + _not_ the range's floor (the normal case, and the one that a package.json-derived tag + would get wrong), and checks the emitted `FROM` tag against the emitted `package.json` + range with a satisfies-check rather than equality. + + `.github/workflows/scaffold-e2e.yml` now reads the tag it builds its local runtime image + under **out of the generated Dockerfile** instead of hardcoding `:latest`. Those were two + hand-matched literals; had they skewed, Docker would have quietly pulled the last + published image instead of the one built from this checkout, and the job's own stated + hermeticity would have been false while it stayed green. + ## 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..903bf3b8dd 100644 --- a/packages/drivers/driver-memory/CHANGELOG.md +++ b/packages/drivers/driver-memory/CHANGELOG.md @@ -1,5 +1,52 @@ # @objectstack/driver-memory +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] +- Updated dependencies [d634e66] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/types@17.1.0 + ## 17.0.0 ### Major Changes @@ -1004,7 +1051,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 +2704,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..c26562ee74 100644 --- a/packages/drivers/driver-mongodb/CHANGELOG.md +++ b/packages/drivers/driver-mongodb/CHANGELOG.md @@ -1,5 +1,52 @@ # @objectstack/driver-mongodb +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] +- Updated dependencies [d634e66] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/types@17.1.0 + ## 17.0.0 ### Major Changes @@ -422,7 +469,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 +2517,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..40133b6a03 100644 --- a/packages/drivers/driver-sql/CHANGELOG.md +++ b/packages/drivers/driver-sql/CHANGELOG.md @@ -1,5 +1,474 @@ # @objectstack/driver-sql +## 17.1.0 + +### Minor Changes + +- 9c4d096: feat(driver-sql): MySQL joins the unresolvable-column predicate — the `INVALID_FILTER` refusal envelope AND the #3821 recoveries, full dialect parity (#8926) + + **BREAKING** accept-set change on a GA public data API — in both directions at + once, on MySQL only — shipped as `minor` under the lockstep launch-window + convention, like the #8790 change it completes. + + + + ## What changes, on MySQL only + + `isUnresolvableColumnError` — the ONE predicate `SqlDriver.findRows()`'s #3821 + recovery ladder and `SqlDriver.count()` both read — now recognises MySQL's + spelling of "the statement named a column the backend could not resolve": + `Unknown column 'x' in 'where clause'` / `'field list'` / `'order clause'` + (`ER_BAD_FIELD_ERROR`). SQLite and Postgres behaviour is untouched. + + Measured on live MySQL 8.0.46 (`SqlDriver` over mysql2), before → after: + + - **WHERE** — `find()` and `count()` alike: raw `ER_BAD_FIELD_ERROR`, no + `status` (an unclassified 5xx at the REST boundary), the statement's bound + literals inlined in the message → refused with `INVALID_FILTER` / 400 naming + the column; the dialect message goes to the server log. The narrowing — the + #7929 predicate-text disclosure shape closed on the last dialect that still + had it. + - **Projection** — `find({ fields: [...] })` naming a column the table lacks + threw the raw error → retries selecting `*`; the rows come back, WHERE + honoured. The widening. + - **ORDER BY** — sorting by a column the table lacks threw the raw error → + drops the sort and returns the rows unordered, WHERE honoured. The widening. + + Both directions were ruled together on #8926 (option A, maintainer, + 2026-08-16); a split predicate — the envelope without the recoveries — was + refused. The widening cannot drop a predicate: every ladder rung is rebuilt + from `buildBase()`, which unconditionally re-applies `query.where`, so the + recoveries reach the projection and the sort only. + + ## Migration + + Nothing stored needs rewriting. A MySQL caller that relied on catching the raw + `ER_BAD_FIELD_ERROR` from `find()`/`count()` should catch `INVALID_FILTER` / + 400 instead — the same envelope SQLite and Postgres already answer, and the + same prescription the registered + `driver-sql-unresolvable-where-column-refused` migration carries. + +- 716ac9b: fix(driver-sql): one unresolvable WHERE column, one answer — `find()` and `count()` both refuse with `INVALID_FILTER` / 400 naming the column (#8790) + + **BREAKING** accept-set narrowing on a GA public data API, shipped as `minor` + under the lockstep launch-window convention. The migration prescription is + registered under protocol major 18, where `os migrate meta` users will look. + + + + ## The defect + + One predicate had two answers. `SqlDriver.findRows()` carries the #3821 + unknown-column recovery ladder, and every rung of it is built from + `buildBase()`, which **always re-applies `query.where`**. So the ladder can drop + a projection and can drop an ORDER BY, but it can never drop the clause that + actually failed when the unresolvable column is in the WHERE — both rungs raise + the same error and the method fell to `return []`. `SqlDriver.count()` runs a + separate statement and has no ladder at all, so the identical predicate threw. + + Measured on a real `SqlDriver` over better-sqlite3, one table, one seeded row: + + ``` + where { 'title.x': 'y' } + find() -> 0 rows, NO ERROR + count() -> THREW code=SQLITE_ERROR status=undefined + select count(*) as `count` from `task` where `title`.`x` = 'y' + - no such column: title.x + + CONTROL where { title: 'Design' } + find() -> 1 row + count() -> 1 + ``` + + A list view calls both halves, so one query produced an empty page from the rows + half and a 500-shaped failure from the total half. A caller reading only the rows + got a silent empty page that says "no records exist" for what was really "your + predicate never ran" — the single most AI-legible failure to get wrong, since an + agent reads "no matching records" and writes its next query on that belief. + + The thrown half was no better: the dialect's own `code`, no `status` (so an + unclassified 5xx at the REST boundary rather than a caller mistake), and the + statement's **bound literals inlined in the message** — the same predicate-text + disclosure shape #7929 redacted elsewhere. + + ## The fix + + Ruled 2026-08-15 on #8790: **refuse both halves** with `INVALID_FILTER` / 400, + naming the column. That envelope is not minted here — it is what every sibling + refusal on this path already answers, required on both SQL drivers by + `cross-field-conformance-cases.ts` and pinned by + `sql-driver-boolean-identity.test.ts` and + `sql-driver-cross-field-conformance.test.ts`. What closes is a + declared-vs-enforced gap, not a new posture. + + The caller-visible message names the column and the object and nothing else. The + dialect's own message — the compiled statement, bound literals and all — goes to + the **server log** instead, so the operator keeps the debugging aid that + `count()`'s raw throw used to provide without it reaching the caller. + + **The #3821 ladder keeps both of its recoveries.** Only the WHERE-failure + terminal `return []` became a refusal, and the asymmetry is the ruling rather + than an oversight: "rows matter more than their order" is an argument about how + rows are _presented_, and it does not transfer to a predicate. A dropped sort is + a correct answer in an unhelpful order; a dropped WHERE is records the caller + explicitly excluded. Recover-both was rejected for exactly that reason. + + ## Reach, stated rather than assumed + + The refusal fires on the wordings the ladder has always recognised — SQLite + (`no such column: x`) and Postgres (`column "x" does not exist`). MySQL spells + the condition `Unknown column 'x' in 'where clause'`, which neither arm matches, + so on MySQL an unresolvable column still travels out as the raw dialect error. + That gap is pinned as a fact in the new suite and filed separately: widening the + predicate would also hand MySQL the #3821 projection and ORDER-BY recoveries it + has never had, which is an accept-set change in the opposite direction from this + one. + + ## Who is affected + + Callers that reach the driver with a filter key the table has no column for. The + ingress doors already refuse this where they can judge — `assertFilterFieldsExist` + (`@objectstack/metadata-protocol`) answers `INVALID_FIELD` / 400 for everything + reaching `findData`, with the sentence this refusal now echoes verbatim: _a + filter on a field that does not exist can only match zero records, so the query + was refused instead of answered with an empty list_. What changes is the + backstop underneath them: a registry the door could not read, and a dotted key + judged on its head segment only. + +- 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_. + +- d00d2f6: fix(driver-sql): refuse — and roll back — a MySQL upsert that merges onto a row the caller never identified (#8807) + + `ON DUPLICATE KEY UPDATE` carries no conflict target, so on MySQL a merge lands on + whichever UNIQUE key the row collides with first. `#8621` closed the half where + nothing backed a caller-named target; `#8755` closed the half where a rival key + could absorb a caller-named one. This closes the residue those two left by + construction: the `conflictKeys`-less call and the `['id']` call, which compile + byte-identically and which no pre-flight can judge, because neither names anything. + + Measured on live MySQL 8.0.46, `email` and `tax_id` both `unique: true`, **no** + `conflictKeys`: seeding `{email:'d@b.com', tax_id:'T-9'}` inserted one row, and + `{email:'e@b.com', tax_id:'T-9'}` then resolved with no error — one row, the + _seeded_ one, its `email` rewritten `d@b.com` to `e@b.com`, and the id the caller + was handed back present in no row at all. The identical pair on SQLite raises + `UNIQUE constraint failed: …tax_id` and leaves the seeded row untouched. + + Per the maintainer ruling on #8807 this enforces a contract principle, not a MySQL + detail: _an `upsert` must never modify a row whose identity the caller did not + supply and whose conflict key it did not name._ + + **Accept-set change, MySQL only.** After the statement and inside the same + transaction, the driver checks whether the row it landed on is the one the call + supplied. If it is not, the write is **rolled back** and the call refuses with + `code: 'VALIDATION_ERROR'`, `status: 400`, naming the UNIQUE key that absorbed the + merge and stating that nothing was changed. + + The check is exact rather than heuristic — `id` is insert-only on the merge path + (#8622), so a row merged on the primary key always still carries the supplied id + and a row merged on any other key never does — which is why it has no false + refusals. + + Deliberately unchanged: tables whose only key is the primary key are not verified + and open no transaction, so the ordinary upsert keeps its single round trip; every + insert and every re-upsert of the same row still merges; the caller-named + single-unique-key fast path is untouched; and SQLite and PostgreSQL are unaffected, + because `ON CONFLICT (...)` already honours the named arbiter. The lifecycle + archiver's hot→cold copy passes by construction — it supplies each row's own id — + and of the two objects declaring `lifecycle.archive`, neither carries a + non-primary unique field. The dialect limit is 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 [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] +- Updated dependencies [d634e66] + - @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 +1630,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 +5505,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..616bbd73a2 100644 --- a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md +++ b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md @@ -1,5 +1,56 @@ # @objectstack/driver-sqlite-wasm +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [9c4d096] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- 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 [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [a4acb8d] +- Updated dependencies [d634e66] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/driver-sql@17.1.0 + ## 17.0.0 ### Major Changes @@ -194,7 +245,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 +1846,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..af42a3e5d5 100644 --- a/packages/drivers/driver-turso/CHANGELOG.md +++ b/packages/drivers/driver-turso/CHANGELOG.md @@ -1,5 +1,115 @@ # @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 [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [9c4d096] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- 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 [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [a4acb8d] +- Updated dependencies [d634e66] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/driver-sql@17.1.0 + ## 17.0.0 ### Major Changes @@ -340,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 @@ -2306,7 +2416,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..4256d0a41b 100644 --- a/packages/formula/CHANGELOG.md +++ b/packages/formula/CHANGELOG.md @@ -1,5 +1,43 @@ # @objectstack/formula +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..c53f22c15f 100644 --- a/packages/lint/CHANGELOG.md +++ b/packages/lint/CHANGELOG.md @@ -1,5 +1,265 @@ # @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. + +- 8640fb2: `os validate`: a dashboard header `modal` action's target resolves against declared PAGES, only (#9013) + + `validateDashboardActionRefs` resolved an `actionType: 'modal'` header button's + `actionUrl` the way objectui's `DashboardView` used to dispatch it: a defined + action name, a bare object name, or the `_` prefix form + (`create_`/`new_`/`add_`/`edit_`/`update_` + a defined object) all passed, and a + target naming a declared page ERRORED unless it collided with one of those. + + That mirror is gone. Maintainer ruling objectstack#6739-A (2026-08-09): a + `type: 'modal'` string target names a PAGE, only — the spec TSDoc, the published + docs and `defineStack`'s cross-reference walk already said so, and objectui#4764 + / objectui#4782 retired the renderer's object fallback and `DashboardView`'s + second copy of the prefix convention (enumerated across both repos' corpora: + zero producers). After that, `os validate` blessed exactly the buttons the + runtime refuses — the false affordance the rule exists to eliminate — while + refusing the one shape the runtime serves. + + **BREAKING** accept-set change on the `os validate` gating tier (landing after + the v17.0.0 cut; the lockstep launch-window convention ships it as `minor`): + + - A `modal` header target naming a defined action, a bare object, or a + `_` form now **fails** validation. Those buttons already + dispatch to a named refusal at runtime. + - A `modal` header target naming a declared page now **passes** — it was + wrongly refused before. + + ## FROM → TO + + ```ts + // before — passed validation; the runtime now refuses the click + header: { + actions: [{ label: 'New Deal', actionType: 'modal', actionUrl: 'create_opportunity' }], + } + + // after — name a declared page… + header: { + actions: [{ label: 'Intake', actionType: 'modal', actionUrl: 'deal_intake' }], // pages: [{ name: 'deal_intake' }] + } + // …or, to open an object's form, use the validated first-class shape + header: { + actions: [{ label: 'New Deal', actionType: 'form', actionUrl: 'opportunity.edit' }], + } + ``` + + There is deliberately no automatic rewrite: a retired-shape target is a + name-shaped guess (`create_opportunity` names the page `create_opportunity`, or + it names nothing — the ruling explicitly declined keeping the prefix), and only + the author knows whether the button meant a page or an object form. + `objectstack migrate meta` surfaces the change as a structured TODO (semantic + entry `dashboard-header-modal-target-page-only`, protocol major 18). + + + +- 8b9eba5: feat(spec): field-level `relatedListFilter` — a declarative default filter for auto-derived related lists (#8704) + + + + The field-level related-list family (`relatedList` / `relatedListTitle` / + `relatedListColumns`) gains its fourth member, `relatedListFilter` — closing the + gap where the only way to filter an auto-derived related list was to abandon the + auto-derived record page for a hand-written `record:related_list` page + (maintainer ruling 2026-08-15 on #8704). + + - **No new filter dialect**: the key carries the canonical Query-DSL + `FilterCondition` (the same authoring face as a query `where`, dataset scope + filters, and `summaryOperations.filter`). The FILTER-axis doors therefore + apply automatically — the schema door refuses bare date-range preset + comparands in ordering positions at parse (#8793), and the engine doors judge + the composed query at run time (`formula` keys refused `INVALID_FIELD`, + #8296). + - **Contract semantics, pinned**: the declared constraint is AND-composed with + the parent-relationship condition `{ [referenceField]: parentId }` — an + authored constraint, never a user-editable suggestion — and the related-list + tab badge count honors the same composed filter, so counts match visible + rows. Both clauses are normative in the key's contract text and pinned by + tests. + - **`@objectstack/lint`**: the shared authored-filter walk (`FILTER_KEYS`) now + recognizes `relatedListFilter`, extending the filter-token, empty-combinator + and preset-comparand rules to the new position. + + The consumption half (RecordDetailView auto-derivation + tab badge) is + objectui#4664, `Blocked-by:` this change; until it lands the key is ledgered + `planned` with an author warning. + +- a777944: feat(spec,lint): refuse a bare date-range preset name in an ordering filter comparand at publish time (#8793 — the ruled C half of #8690) + + **BREAKING** accept-set narrowing on a published authoring surface, 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). + + `last_7_days` / `last_30_days` / `last_90_days` and their ten calendar + siblings are real, declared preset names — for the dashboard date-filter + positions, where the console lowers them to `{date-macro}` bounds before any + query is sent. Authored as a bare filter comparand nothing resolves them: + measured on #8690, `$gte "last_30_days"` returned HTTP 200 with 0 of 51 rows + where `$gte "{30_days_ago}"` returned the 38 in-window. The engine now + refuses the bare name on a declared temporal field at query time + (`INVALID_FILTER` / 400, PR #8808 — the B half); this change is the + authoring-time half the same ruling shipped alongside it. + + **What is refused — ordering positions only, in all three authored filter + shapes:** a `$gt` / `$gte` / `$lt` / `$lte` comparand or `$between` endpoint + on every carrier of `FilterConditionSchema` (dashboard widget filter, dataset + filter, report `runtimeFilter`, page/component filter, rollup filter), a + `greater_than` / `less_than` / `before` / `after` / `between` view filter + rule value, and an ordering `[field, op, value]` filter triple (the latter + two via `@objectstack/lint`'s new gating rule `filter-preset-comparand`, + which also runs at the runtime publish gate for `dashboard` / `view` / + `object` / `page` / `flow` writes). The refusal names the offending value, + the position, and the exact `{date-macro}` window that works. + + **What stays accepted:** the preset names in the dashboard date-filter + positions (`dateRange.defaultRange`, a date global filter's `defaultValue`) — + the only positions any layer ever resolved them; equality and membership + comparands (`{ period: 'this_quarter' }`, `$in: [...]`) — a select/picklist + column legitimately stores colliding values, and the engine's field-typed + door already covers the temporal case; undeclared strings + (`'not-a-date-at-all'`) — the field-typed engine door owns those; and the + empty-string cell, which stays its own card by ruling. + + ## FROM → TO + + ```ts + // before — parsed green, returned a silent zero (or 400 at query time since #8808) + filter: { + closed_at: { + $gte: "last_30_days"; + } + } + + // after — rejected naming the window; write the date-macro spelling + filter: { + closed_at: { + $gte: "{30_days_ago}"; + } + } + // calendar presets prescribe their pair: + filter: { + closed_at: { + $between: ["{week_start}", "{week_end}"]; + } + } + ``` + + `DATE_RANGE_PRESETS` moved to `@objectstack/spec/data` + (`data/date-range-presets.ts`) with `ui` re-exporting it, so both import + paths keep working; `DATE_RANGE_PRESET_MACRO_WINDOWS` (the per-preset macro + window table the refusals quote) and `isDateRangePresetName` are new exports. + + + +- 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. + +- 192213f: Three write-surface lint rules now ask provenance, not just membership, before exempting a system column (#8663). + + `validate-hook-body-writes`, `validate-action-body-writes` and `validate-flow-node-writes` share one `IMPLICIT_FIELDS` set, which is object-INDEPENDENT: it answers "could this name be implicitly writable somewhere", never "did the platform provision a column for it on THIS object". On an ADR-0015 `external` object those diverge — the registry injects `owner_id` / `organization_id` / the audit family onto a federated object exactly as onto a local one, but the remote database owns the schema and no column exists behind them. + + Each rule now emits a new advisory finding on that path instead of staying silent — `hook-body-write-unprovisioned-anchor`, `action-body-write-unprovisioned-anchor`, `flow-node-write-unprovisioned-anchor` — sharing the `unprovisionedAnchorCause` / `unprovisionedAnchorHint` wording the read-axis rules already use. All three are `warning`: the flow-node rule's existence finding still gates at `error`, and its provenance finding deliberately does not, because the claim is about a remote schema this repo cannot see. + + An author-DECLARED column of the same name is untouched — on a federated object it maps a remote column the author vouches for. `FlowNodeWriteSeverity` widens from `'error'` to `'error' | 'warning'` accordingly. + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @objectstack/spec@17.1.0 + - @objectstack/formula@17.1.0 + - @objectstack/sdui-parser@17.1.0 + ## 17.0.0 ### Minor Changes @@ -3125,7 +3385,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 +7643,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..dcc53e9c1e 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,117 @@ # @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 [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] +- Updated dependencies [d634e66] + - @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 be1bd1acc4..a85817ad2e 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..56fda1b61c 100644 --- a/packages/metadata-core/CHANGELOG.md +++ b/packages/metadata-core/CHANGELOG.md @@ -1,5 +1,186 @@ # @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. + +- 1a7f907: fix(metadata): a package publish refuses a draft stored under a non-canonical metadata type, and the ADR-0010 audit writer asserts its `type` instead of folding it (#8908) + + + + **Two tightenings, one card, because they are the same defect at two layers.** + + `publishPackageDrafts` reads `sys_metadata` rows **at rest**, so #7894's `/meta` + boundary fold never reached it. `promoteDraftForPublish` folds the stored + spelling through `PLURAL_TO_SINGULAR` — the _manifest-collection_ map, which + legitimately omits types that are not stack collections. For those the fold is a + **no-op**: the lookup key equals the stored spelling, the draft resolves, and the + publish mints an ACTIVE row in the namespace `PUT /meta/field/…` answers + 403 NOT_OVERRIDABLE for. Measured on the card with the real repository over a + stub engine: + + ``` + publishPackageDrafts({ packageId: 'app.demo' }) + → { success: true, publishedCount: 1, published: [{ type: 'fields', name: 'legacy_field' }] } + active row: { type: 'fields', name: 'legacy_field', package_id: 'app.demo' } + audit row: { type: 'fields', name: 'legacy_field', outcome: 'allowed', code: 'ok' } + ``` + + Every registry read and every compliance query on `field` misses an item the + platform just reported as published — the #4432 shadowing shape, minted at + publish time instead of at the URL, and the last route by which a pre-#7894 row + could be re-promoted rather than migrated. + + **1. The publish refuses it, at the pre-flight, batch-atomically.** Same shape as + the ADR-0028 namespace-prefix gate that already stands there: found before + anything is promoted, failing the whole batch (`publishedCount: 0`, + `published: []`) rather than publishing the healthy siblings around it, with one + audit row per violation. The refusal names the row, names the canonical type, and + states the re-author path; `failed[].code` is the new + `STORED_TYPE_NOT_CANONICAL`, and the audit column's spelling is + `stored_type_not_canonical`. + + The rule is **derived, not a list**: a spelling the platform's URL/registry map + folds elsewhere _and_ the manifest map leaves unchanged. Against the real maps + that is **six** spellings — `fields`, `seeds`, `external_catalogs`, + `externalCatalogs`, `translations`, `email_templates` — where the card named + four; the last two would have been missing from any hand-written list, and a + newly declared type that never reaches the manifest map is covered on the day it + is declared. A manifest-**present** plural (`objects`) is deliberately _not_ in + the class: it is already fail-closed at the promote (`NO_DRAFT`, batch aborted) + and keeps that verdict. + + ⛔ Deliberately **not** included: migrating the row (a `_migrate-stored` / + boot-reconciliation conversion). That was the other option on the card and is + explicitly unruled — it stays available as a follow-up with its own appetite. + + **2. `recordMetadataAudit` refuses a non-canonical `type` (`AUDIT_TYPE_NOT_CANONICAL`) + instead of folding it.** The writer used to open with + `type: PLURAL_TO_SINGULAR[entry.type] ?? entry.type` — a lenient consumer, and a + **tolerant-and-incomplete** one: the fold read the same manifest map, so the + compliance trail came out canonical for the 29 types that never needed it and + non-canonical for exactly the ones that did. Ruled the same direction as the + refusal above: **fold at the boundary, assert at the writer.** Every call site + that builds a row out of an at-rest `type` — all of them on + `publishPackageDrafts` — now folds with `canonicalMetaType`; the `/meta` routes + were already canonical by the time they got there. The throw sits **outside** the + writer's best-effort `try`, because inside it the method's own `catch` would + degrade the assert into a `console.warn`. + + The assert cannot refuse a canonical type (no canonical spelling folds + elsewhere — 33 of 33, measured) nor a plugin-registered or otherwise + unrecognised kind (`canonicalMetaType` is the identity for anything the static + map does not carry), so it narrows the accept set without closing it. + + **Reachability was enumerated before the assert landed**, as the ruling required: + `recordMetadataAudit` is private to `protocol.ts` with 11 call sites, `sys_metadata` + rows have exactly one producer in the repository (`saveMetaItem` → `repo.put`, + post-fold), and no current write path can mint a non-canonical stored type. The + only non-canonical types that ever reached an audit write came from the batch + publish's at-rest rows, which is what the boundary folds now cover. + + Also fixed, as a consequence of that fold rather than as a separate change: on + the batch route `getEffectiveLock`'s overlay limb was queried with the raw stored + spelling, so an ADR-0010 `_lock` carried by the canonical active row was looked + up under a `type` no row has and came back `'none'` — the verdict "the author + declared no protection". That is the batch twin of the hole #8769 closed on + `publishMetaItem`. + +- 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 [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..3a7a7cc6c7 100644 --- a/packages/metadata-fs/CHANGELOG.md +++ b/packages/metadata-fs/CHANGELOG.md @@ -1,5 +1,14 @@ # @objectstack/metadata-fs +## 17.1.0 + +### Patch Changes + +- Updated dependencies [845e164] +- Updated dependencies [1a7f907] +- 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..cf07fa208a 100644 --- a/packages/metadata-protocol/CHANGELOG.md +++ b/packages/metadata-protocol/CHANGELOG.md @@ -1,5 +1,1158 @@ # @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. + +- a8189ae: feat(objectql,metadata-protocol): refuse a dotted filter key whose head is a relation, a formula, or a plain scalar — at both doors (#8371) + + + + **BREAKING** accept-set narrowing on the FILTER axis, 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 `objectstack migrate +meta` users will look). + + FILTER was the last of the four query axes with no verdict for a dotted name: + SORT refuses it (#4256), PROJECTION refuses it at both doors (#7589), while + `where: { 'project_id.name': 'Apollo' }` cleared the unknown-field check on its + head segment and answered `200` with zero rows. Measured across all three + drivers before ruling (#8371): relation-head, formula-head, system-column-head + and plain-scalar-head dotted filters return zero rows on `driver-memory`, + `driver-sql` and `driver-mongodb` alike — a lookup stores the related record's + scalar id, so there is no working capability for this refusal to remove; every + answer was a silent empty list indistinguishable from an empty table, and the + virtual case answered one unserviceable intent two ways by spelling + (`{is_open: true}` refused since #8296, `{'is_open.x': true}` not). + + **What is refused:** a dotted filter key whose head field is a relation + (`lookup`/`master_detail`/`user`/`tree`), a virtual `formula`, or a plain + scalar — `400 INVALID_FIELD`, naming the whole offending key, at both the REST + ingress (`assertFilterFieldsExist`) and the engine's own filter seam + (`assertFilterIsMaterializable`, reached by saved reports, flows and dashboard + widgets whose filters never pass the ingress). Both doors judge the head by the + shared `@objectstack/spec/data` classification (`classifyDottedFilterHead`, + new export), so they cannot drift apart. Precedence mirrors the sort axis: + `unknown` > `dotted` > unmaterializable. + + **What stays accepted:** a dotted path into a structured/JSON head + (`{'address.city': 'Beijing'}`) — deliberately unjudged per the ruling, since + it genuinely works on two of three backends; array-valued and file heads, for + the same reason; the nested-relation OBJECT form `{ owner: { region: 'NA' } }`; + and every undotted spelling, byte-identically. + + ## FROM → TO + + ```ts + // before — 200, zero rows, indistinguishable from an empty table + await engine.find("task", { where: { "project_id.name": "Apollo" } }); + + // after — 400 INVALID_FIELD naming 'project_id.name', with the remedy: + // denormalise the value onto a stored field of the queried object and + // filter that (or, to test the relation itself, filter the head field): + await engine.find("task", { where: { project_id: apolloId } }); + ``` + + There is deliberately no automatic rewrite: the platform cannot invent the + stored column the remedy prescribes, and it must not join or post-filter + instead — the drivers have already applied `limit`/`offset`, so a post-hoc + predicate would filter an arbitrary page. + +- 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. + +- 7b3c033: Publishing a package no longer promotes another package's draft row. + + `publishPackageDrafts` lists a package's pending drafts with + `listDrafts({ packageId })`, but the promotion then re-resolved each row without + the ADR-0048 `package_id` dimension. Overlay rows are keyed by + `(org, type, name, package_id)` precisely so two installed packages shipping the + same name keep separate rows, so that lookup could not tell them apart: with two + packages holding drafts for the same `(type, name)`, publishing package A + promoted package B's unreviewed draft to active, drained B's draft row, recorded + it under A's ADR-0067 commit and ADR-0010 audit row, and left A's own edit still + pending — while answering `success: true`. Which of the two rows won was + driver-order dependent, so on a real driver this was a coin toss per publish. + + The listed row's `package_id` is now threaded through to the promotion, which + resolves and drains the draft under the same key it was listed by. Publishes + that name no package (`publishMetaItem`) are unchanged. + +- 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. + +- b740440: fix(metadata-protocol): the stored migration reports a non-canonical stored `type` as `skipped` instead of counting it `canonical` (#8957) + + `migrateStoredMetadata` — the method behind `POST /meta/_migrate-stored` and + `os migrate meta --stored` — opened every row with + `PLURAL_TO_SINGULAR[rawType] ?? rawType`, the **manifest-collection** map. That + map legitimately omits the metadata types that are not stack collections, so + for a row stored under one of their plural spellings the fold was a no-op: the + pass looked up ADR-0087 body conversions registered for a type named `fields`, + found none, saw nothing had changed, and recorded the row `canonical`. + + `canonical` is counted and never itemised — by design, because on a healthy + deployment that is every row — so the row disappeared from `report.rows` + altogether. The verdict means "nothing to do", and there was something to do: + the row sits in a second namespace that no registry read and no compliance + query on the canonical type can reach. + + Since #8908, `publishPackageDrafts` **refuses** exactly these rows at its + pre-flight (`STORED_TYPE_NOT_CANONICAL`). The stored migration is the door an + operator naturally reaches for next, and it answered that the row was already + fine. The two doors now agree. + + ## What changed in the report + + The scan folds with the URL/registry map (`canonicalMetaType`) instead of the + manifest map, and a row whose **stored** spelling is non-canonical is reported: + + ```jsonc + // before — the row was invisible + { "scanned": 1, "canonical": 1, "skipped": 0, "rows": [] } + + // after + { + "scanned": 1, "canonical": 0, "skipped": 1, + "rows": [{ + "type": "field", "name": "showcase_task.title", "outcome": "skipped", + "reason": "the row is stored under the non-canonical metadata type 'fields' ('fields/showcase_task.title'), and its canonical type is 'field'. …" + }] + } + ``` + + The reason names the stored spelling in the same `type/name` form the publish + refusal quotes, the canonical type, the other door's error code, and the + re-author path. `--type field` and `--type fields` now both reach the row — + the filter folds the same way, so the spelling an operator was just handed by + the publish refusal is not the one spelling that fails to find it. + + The fold swap cannot change the answer for any spelling the old fold resolved: + `META_URL_TO_SINGULAR` embeds every manifest spelling verbatim under a + module-load agreement assertion, and measured on this tree the set of spellings + where the two folds disagree is empty. The set the new fold newly resolves is + exactly the six-member class `isNonCanonicalStoredType` derives (`fields`, + `seeds`, `external_catalogs`, `externalCatalogs`, `translations`, + `email_templates`), which is the set now reported. + + ## What did NOT change + + The method's contract. It still canonicalizes **bodies**, and it still writes + nothing for this class: rewriting a stored `type` is an identity move — a new + `(org, type, name, package_id)` key, history and audit continuity to decide, + and a collision question when the canonical row already exists — which #8908's + ruling parked as a follow-up needing its own appetite. + + `storedMigrationClean` is also unchanged: `skipped` rows still do not flip it. + This pass has no lever for the condition, so failing the verdict over it would + give `os migrate meta --stored` a non-zero exit that no run of that command + could ever clear. The row is reported per-row instead, and the publish door is + what refuses it. + +- 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. + +- 1a7f907: fix(metadata): a package publish refuses a draft stored under a non-canonical metadata type, and the ADR-0010 audit writer asserts its `type` instead of folding it (#8908) + + + + **Two tightenings, one card, because they are the same defect at two layers.** + + `publishPackageDrafts` reads `sys_metadata` rows **at rest**, so #7894's `/meta` + boundary fold never reached it. `promoteDraftForPublish` folds the stored + spelling through `PLURAL_TO_SINGULAR` — the _manifest-collection_ map, which + legitimately omits types that are not stack collections. For those the fold is a + **no-op**: the lookup key equals the stored spelling, the draft resolves, and the + publish mints an ACTIVE row in the namespace `PUT /meta/field/…` answers + 403 NOT_OVERRIDABLE for. Measured on the card with the real repository over a + stub engine: + + ``` + publishPackageDrafts({ packageId: 'app.demo' }) + → { success: true, publishedCount: 1, published: [{ type: 'fields', name: 'legacy_field' }] } + active row: { type: 'fields', name: 'legacy_field', package_id: 'app.demo' } + audit row: { type: 'fields', name: 'legacy_field', outcome: 'allowed', code: 'ok' } + ``` + + Every registry read and every compliance query on `field` misses an item the + platform just reported as published — the #4432 shadowing shape, minted at + publish time instead of at the URL, and the last route by which a pre-#7894 row + could be re-promoted rather than migrated. + + **1. The publish refuses it, at the pre-flight, batch-atomically.** Same shape as + the ADR-0028 namespace-prefix gate that already stands there: found before + anything is promoted, failing the whole batch (`publishedCount: 0`, + `published: []`) rather than publishing the healthy siblings around it, with one + audit row per violation. The refusal names the row, names the canonical type, and + states the re-author path; `failed[].code` is the new + `STORED_TYPE_NOT_CANONICAL`, and the audit column's spelling is + `stored_type_not_canonical`. + + The rule is **derived, not a list**: a spelling the platform's URL/registry map + folds elsewhere _and_ the manifest map leaves unchanged. Against the real maps + that is **six** spellings — `fields`, `seeds`, `external_catalogs`, + `externalCatalogs`, `translations`, `email_templates` — where the card named + four; the last two would have been missing from any hand-written list, and a + newly declared type that never reaches the manifest map is covered on the day it + is declared. A manifest-**present** plural (`objects`) is deliberately _not_ in + the class: it is already fail-closed at the promote (`NO_DRAFT`, batch aborted) + and keeps that verdict. + + ⛔ Deliberately **not** included: migrating the row (a `_migrate-stored` / + boot-reconciliation conversion). That was the other option on the card and is + explicitly unruled — it stays available as a follow-up with its own appetite. + + **2. `recordMetadataAudit` refuses a non-canonical `type` (`AUDIT_TYPE_NOT_CANONICAL`) + instead of folding it.** The writer used to open with + `type: PLURAL_TO_SINGULAR[entry.type] ?? entry.type` — a lenient consumer, and a + **tolerant-and-incomplete** one: the fold read the same manifest map, so the + compliance trail came out canonical for the 29 types that never needed it and + non-canonical for exactly the ones that did. Ruled the same direction as the + refusal above: **fold at the boundary, assert at the writer.** Every call site + that builds a row out of an at-rest `type` — all of them on + `publishPackageDrafts` — now folds with `canonicalMetaType`; the `/meta` routes + were already canonical by the time they got there. The throw sits **outside** the + writer's best-effort `try`, because inside it the method's own `catch` would + degrade the assert into a `console.warn`. + + The assert cannot refuse a canonical type (no canonical spelling folds + elsewhere — 33 of 33, measured) nor a plugin-registered or otherwise + unrecognised kind (`canonicalMetaType` is the identity for anything the static + map does not carry), so it narrows the accept set without closing it. + + **Reachability was enumerated before the assert landed**, as the ruling required: + `recordMetadataAudit` is private to `protocol.ts` with 11 call sites, `sys_metadata` + rows have exactly one producer in the repository (`saveMetaItem` → `repo.put`, + post-fold), and no current write path can mint a non-canonical stored type. The + only non-canonical types that ever reached an audit write came from the batch + publish's at-rest rows, which is what the boundary folds now cover. + + Also fixed, as a consequence of that fold rather than as a separate change: on + the batch route `getEffectiveLock`'s overlay limb was queried with the raw stored + spelling, so an ADR-0010 `_lock` carried by the canonical active row was looked + up under a `type` no row has and came back `'none'` — the verdict "the author + declared no protection". That is the batch twin of the hole #8769 closed on + `publishMetaItem`. + +- 4e3a4c3: fix(metadata-protocol): four read seams that FAILED no longer answer out of an empty accumulator — only an unprovisioned table is read as truthful emptiness (#8896) + + Four reads in `@objectstack/metadata-protocol` sat behind a bare `catch` that + fell through — or, in one case, jumped — above a value the read was supposed to + fill. Each handed its caller an answer indistinguishable from a legitimate one, + with nothing logged and no field saying the answer was incomplete. Per ADR-0110 + D3 those are different facts, and at every one of these seams they have opposite + consequences: + + - **`SeedLoaderService.loadExistingRecords()`** returned an empty `Map`. That map + is not a cache — it IS the write decision, in all three of its callers, and + "empty" means _write these rows_: the upsert pre-load turns every update into + an INSERT, and `bulkWrite`'s `attempt > 1` recheck — the only thing standing + between an at-least-once retry and a duplicate of every row the first attempt + already committed (framework#3149) — is silently disarmed. + - **`searchAll()`** skipped the object on a per-object `catch { continue; }` + while the response still reported `totalObjects` / `totalHits` / `truncated` + as though the sweep had been complete: a partial scan wearing a whole one's + numbers. + - **`findReferencesToMeta()`** dropped a whole source type on a per-matcher + `catch { return; }`. That list answers "what would break if I delete this" and + is rendered as the admin UI's "Used by" panel, so a silently short list reads + as "nothing depends on it — safe to remove". + - **`publishPackageDrafts()`** did not fall through: it pushed a **fabricated** + ADR-0067 revert-plan entry, `{ existedBefore: false, prevVersion: null }` — + the literal opposite of the healthy branch's `existedBefore: !!activeRow`. + `existedBefore: false` means "revert = soft-remove", so reverting that commit + DELETES an artifact whose previous version was supposed to be restored. + + None of the four `catch`es is removed; each is **discriminated by error type**, + through the same shared `isMissingTableError` predicate + (`@objectstack/metadata/errors`) that `DatabaseLoader`, `SysMetadataRepository` + and `cascadeDeleteRelations` already use: + + - **benign, unchanged** — the table was never provisioned (schema sync not run + yet). It can hold no rows, so the empty answer is the truth and each seam + behaves exactly as before: the seed writes its rows, the search skips the + object, the publish records `existedBefore: false`. + - **everything else now surfaces** — a connection drop, a timeout, a permission + denial, a query error, a missing column on a provisioned table. The caller + receives the read's own failure, envelope intact. + + `findReferencesToMeta` is the one seam that gets no predicate of its own: it + reads through `getMetaItems`, which already performs exactly this discrimination + (`rethrowUnlessMetadataStoreUnprovisioned`, #5532) and raises a 503 + `SERVICE_UNAVAILABLE` for a real outage. The only thing its `catch` could + swallow was that deliberate 503, so it is simply gone. + + No new error code and no new response field. The behavioural change is that a + seed load, a global search, a reference scan or a package publish which used to + report success over an unreadable store now reports the failure that made it + unreadable. `publishPackageDrafts` refuses before Phase 1's transaction, so a + refused publish leaves the draft pending and writes nothing. + + The comment above the publish capture claimed a capture failure "just omits that + item from the revert plan". That was wrong twice — the code fabricated rather + than omitted, and omitting would have left the item unreverted while reporting + the turn undone — and it now describes what the code does. + +- 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. + +- d5156b9: Remove the four dead `'objects'` spelling tolerances in the metadata protocol's object registry and storage seams. + + `applyObjectRegistryMutation`, `applyRegistryWriteThrough`, `ensureObjectStorage` and `dropObjectStorage` each admitted a plural `'objects'` type key, and the first of them _registered under it_ — the spelling-tolerant-lookup shape `canonicalMetaType`'s header rejects, and the one that previously let a plural registry entry shadow an entire code-authored listing. + + All four are unreachable: every producer folds the type through `PLURAL_TO_SINGULAR` / `canonicalMetaType` before these seams see it. No behaviour changes for any caller that folds — which is all of them. What changes is the failure mode of a future caller that does _not_ fold: it no longer silently registers an object under a plural key, so `assertObjectRegistered` fails closed with a loud, recoverable error instead. + + Folding at the producer remains the rule; these guards were never a second line of defence. + +- 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 [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [845e164] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [7fc01db] +- Updated dependencies [b849e69] +- Updated dependencies [192213f] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] +- Updated dependencies [d634e66] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/lint@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/metadata-core@17.1.0 + - @objectstack/metadata@17.1.0 + - @objectstack/formula@17.1.0 + ## 17.0.0 ### Major Changes @@ -364,7 +1517,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 +2886,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..4c9b15a4d3 100644 --- a/packages/metadata/CHANGELOG.md +++ b/packages/metadata/CHANGELOG.md @@ -1,5 +1,65 @@ # @objectstack/metadata +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [845e164] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [7fc01db] +- Updated dependencies [8bee54b] +- 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] +- Updated dependencies [d634e66] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/metadata-core@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..be390a1861 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,367 @@ # @objectstack/objectql +## 17.1.0 + +### Minor Changes + +- a8189ae: feat(objectql,metadata-protocol): refuse a dotted filter key whose head is a relation, a formula, or a plain scalar — at both doors (#8371) + + + + **BREAKING** accept-set narrowing on the FILTER axis, 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 `objectstack migrate +meta` users will look). + + FILTER was the last of the four query axes with no verdict for a dotted name: + SORT refuses it (#4256), PROJECTION refuses it at both doors (#7589), while + `where: { 'project_id.name': 'Apollo' }` cleared the unknown-field check on its + head segment and answered `200` with zero rows. Measured across all three + drivers before ruling (#8371): relation-head, formula-head, system-column-head + and plain-scalar-head dotted filters return zero rows on `driver-memory`, + `driver-sql` and `driver-mongodb` alike — a lookup stores the related record's + scalar id, so there is no working capability for this refusal to remove; every + answer was a silent empty list indistinguishable from an empty table, and the + virtual case answered one unserviceable intent two ways by spelling + (`{is_open: true}` refused since #8296, `{'is_open.x': true}` not). + + **What is refused:** a dotted filter key whose head field is a relation + (`lookup`/`master_detail`/`user`/`tree`), a virtual `formula`, or a plain + scalar — `400 INVALID_FIELD`, naming the whole offending key, at both the REST + ingress (`assertFilterFieldsExist`) and the engine's own filter seam + (`assertFilterIsMaterializable`, reached by saved reports, flows and dashboard + widgets whose filters never pass the ingress). Both doors judge the head by the + shared `@objectstack/spec/data` classification (`classifyDottedFilterHead`, + new export), so they cannot drift apart. Precedence mirrors the sort axis: + `unknown` > `dotted` > unmaterializable. + + **What stays accepted:** a dotted path into a structured/JSON head + (`{'address.city': 'Beijing'}`) — deliberately unjudged per the ruling, since + it genuinely works on two of three backends; array-valued and file heads, for + the same reason; the nested-relation OBJECT form `{ owner: { region: 'NA' } }`; + and every undotted spelling, byte-identically. + + ## FROM → TO + + ```ts + // before — 200, zero rows, indistinguishable from an empty table + await engine.find("task", { where: { "project_id.name": "Apollo" } }); + + // after — 400 INVALID_FIELD naming 'project_id.name', with the remedy: + // denormalise the value onto a stored field of the queried object and + // filter that (or, to test the relation itself, filter the head field): + await engine.find("task", { where: { project_id: apolloId } }); + ``` + + There is deliberately no automatic rewrite: the platform cannot invent the + stored column the remedy prescribes, and it must not join or post-filter + instead — the drivers have already applied `limit`/`offset`, so a post-hoc + predicate would filter an arbitrary page. + +### Patch Changes + +- a751f7d: fix(objectql): a cascade-delete dependents probe that FAILS no longer skips the referential guard — only an unprovisioned child table is read as "no dependents" (#8895) + + `ObjectQL.cascadeDeleteRelations()` probes each child relation + (`find(child, { where: { fk: id } })`) to decide what the parent's delete must + do. That probe **is** the referential-integrity guard, and it sat behind a bare + `catch { continue; }` — so **any** failure of it (a connection drop, a timeout, + a permission denial, a query error, a missing column) was indistinguishable + from "this child has no rows": + + - a `deleteBehavior: 'restrict'` relation never refused the delete, so a delete + the integrity rules say must be **refused was allowed through**; + - `set_null` / `cascade` never ran, so child rows that should have been nulled + or removed were **left orphaned**, pointing at a parent that no longer exists; + - nothing was logged and nothing was returned, so the caller was told the + delete **succeeded**. + + That is fail-OPEN on an integrity guard: the read never happened and the answer + "there are none" was invented for it (ADR-0110 D3 — "the probe found nothing" + and "the probe could not run" are different facts, and here they have opposite + meanings). + + The `catch` is not removed; it is **discriminated by error type**, through the + same shared `isMissingTableError` predicate (`@objectstack/metadata/errors`) + that `seedAutonumber` and `resolveFileReferences` already use: + + - **benign, unchanged** — the child object is registered but its **table** was + never provisioned (schema sync not run yet). It cannot hold a row referencing + anything, so zero dependents is the truth and the relation is skipped exactly + as before. + - **everything else now surfaces** — the delete fails with the probe's own + error, envelope intact, and nothing is written. A guard that could not be + **evaluated** must not silently pass. + + No new error code, no new response field: the caller receives the failure the + probe itself raised. The only behavioural change is that a delete which used to + report success over an unreadable child relation now reports the failure that + made the relation unreadable. + +- 4e71ae1: lifecycle: a failed governance row-count probe is no longer indistinguishable from a quiet object + + `LifecycleService.checkGovernance()` probed each declared object's row count and swallowed + every failure with a bare `catch { continue }`. A driver outage therefore read exactly like + an object with nothing to alert on: no `quota-exceeded`, no `growth`, nothing logged, and + nothing in the sweep report — and because the failed object also dropped out of the count + map that becomes the next sweep's baseline, the next sweep could not alert on growth for it + either. + + The probe now discriminates by error type through the shared `isMissingTableError` + predicate. An unprovisioned table is truthful emptiness and stays silent; every other + failure is reported per object in the sweep report's existing `errors` list and logged at + `warn`, both naming the lost growth baseline. No new report field, no new error code, and + the sweep is still isolated — one object's failed probe never costs the others their + governance. + +- 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 [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [177442d] +- Updated dependencies [950bd94] +- Updated dependencies [3043e98] +- Updated dependencies [716ac9b] +- Updated dependencies [7b3c033] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [fd6bdf8] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [ead96d0] +- Updated dependencies [b69d0f5] +- Updated dependencies [c15eb23] +- Updated dependencies [4d47afe] +- Updated dependencies [b740440] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [845e164] +- Updated dependencies [8d017eb] +- Updated dependencies [1a7f907] +- Updated dependencies [4e3a4c3] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [7fc01db] +- Updated dependencies [88ef34d] +- Updated dependencies [add2d19] +- Updated dependencies [2b9d33a] +- Updated dependencies [8bee54b] +- Updated dependencies [0f59584] +- Updated dependencies [ff08691] +- Updated dependencies [159e299] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [d5156b9] +- Updated dependencies [75e66fc] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [a726154] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] +- Updated dependencies [d634e66] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/metadata-protocol@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/metadata-core@17.1.0 + - @objectstack/metadata@17.1.0 + - @objectstack/formula@17.1.0 + ## 17.0.0 ### Major Changes @@ -1010,8 +1372,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 +1560,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 +3991,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 +11613,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 +12143,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..b4383e867f 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -1,5 +1,43 @@ # @objectstack/observability +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..711b75a9ac 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -1,5 +1,508 @@ # @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. + +- d6e80b2: fix(security): `sys_account.password` and `previous_password_hashes` stop serializing on the data API — `internal: true`, with the raw-engine readers converted to the privileged accessor (#8676) + + + + `sys_account.password` (the credential hash) and `previous_password_hashes` (the + ADR-0069 D1 reuse-prevention ring) serialized on `/api/v1/data/sys_account`, + which declares `apiEnabled: true, apiMethods: ['get','list']` — to an **admin + for every user's row**, and to a **member for their own** (the + `sys_account_self` RLS policy grants `select` on `user_id == current_user.id`). + + These are one-way hashes, not reversible outbound credentials — which is why + #7987 correctly refused to bundle them with the OAuth tokens. But a served + password hash is an offline-cracking target, and `previous_password_hashes` + multiplies it by the history ring while its own declaration says it is _never + exposed in UI_. This is the disposition #7728 already reached for + `sys_api_key.key`, which was **also** a stored hash and was still ruled unfit to + serialize through the API face. + + Neither credential collector could reach them: `collectMaskedReadFields` keys on + the field **TYPE** (`secret` / `password`) _and_ exempts objects declaring + `managedBy: 'better-auth'`, which this object is — while these columns are + `text` / `textarea`. Two independent barriers, both missing. + + **The fix is two declarations plus two recovery seams**, and the second seam is + the part a bare flag would have missed: + + - both columns are declared `internal: true` — the opt-in, type-independent flag + from #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's adapter readers** are recovered by the existing per-object + readback table, widened with `password`: the sign-in verifier compares against + the hash on the row `internalAdapter.findCredentialAccount(userId)` returns, + so the strip alone would break password sign-in for every user. + - **plugin-auth's own RAW-engine readers** are recovered by a new seam in the + same module, `recoverInternalFieldsForSystemRead`. This is the half that makes + the flag safe: the readback table is imported by exactly one file + (better-auth's storage adapter), so it cannot reach a caller that reads the + engine directly — and the engine's strip has **no `isSystem` carve-out** by + #7728's design. Measured against a real ObjectQL engine: the reuse ring's + `findOne` returns `{"id":"a1"}` for a query that names both columns in an + explicit projection under `context: { isSystem: true }`. + + Left unrecovered, `assertPasswordNotReused` would become a **silent no-op** — + its comparison list empties, the loop never runs, `PASSWORD_REUSE` is never + thrown, and its own `catch { return undefined }` means nothing announces it. + The ADR-0069 D1 control would report success while accepting every reused + password. Its unit tests would have stayed green throughout, because they use + fake engines that never apply the strip. + + **No ADR-0100 guard change, and none was needed.** `Engine.resolveInternalField` + has exactly one predicate — `internal === true` — so flagging the columns makes + them legitimately dereferenceable through the privileged accessor. The ADR-0100 + sentence in its refusal message is prose explaining why a _non-flagged_ field has + other channels, not a second predicate; the guard stays exactly as selective as + it was, and a non-flagged column on the same object is still refused with + `INVALID_FIELD` / 400. + + Regression proof drives both directions on a real booted stack: both columns are + absent for both personas — including a caller who spells them out in `?select=` — + while the values remain on disk and reachable through the privileged accessor, + password sign-in still works, and the reuse ring still grows across a password + change on every transport lane. + +- 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 [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [845e164] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [7fc01db] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @objectstack/spec@17.1.0 + - @objectstack/metadata-core@17.1.0 + ## 17.0.0 ### Major Changes @@ -2866,7 +3369,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..543bd7806a 100644 --- a/packages/plugins/embedder-openai/CHANGELOG.md +++ b/packages/plugins/embedder-openai/CHANGELOG.md @@ -1,5 +1,43 @@ # @objectstack/embedder-openai +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..dbb5aaabaa 100644 --- a/packages/plugins/knowledge-memory/CHANGELOG.md +++ b/packages/plugins/knowledge-memory/CHANGELOG.md @@ -1,5 +1,50 @@ # @objectstack/knowledge-memory +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..ccbb95d32d 100644 --- a/packages/plugins/knowledge-ragflow/CHANGELOG.md +++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md @@ -1,5 +1,50 @@ # @objectstack/knowledge-ragflow +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..8e6bc6757f 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -1,5 +1,120 @@ # @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 [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [845e164] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [7fc01db] +- Updated dependencies [8bee54b] +- 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] +- Updated dependencies [d634e66] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/metadata-core@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..04833f6449 100644 --- a/packages/plugins/plugin-audit/CHANGELOG.md +++ b/packages/plugins/plugin-audit/CHANGELOG.md @@ -1,5 +1,145 @@ # @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 [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [a751f7d] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4e71ae1] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- 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 [d634e66] + - @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..df504a5395 100644 --- a/packages/plugins/plugin-auth/CHANGELOG.md +++ b/packages/plugins/plugin-auth/CHANGELOG.md @@ -1,5 +1,461 @@ # 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. + +- d6e80b2: fix(security): `sys_account.password` and `previous_password_hashes` stop serializing on the data API — `internal: true`, with the raw-engine readers converted to the privileged accessor (#8676) + + + + `sys_account.password` (the credential hash) and `previous_password_hashes` (the + ADR-0069 D1 reuse-prevention ring) serialized on `/api/v1/data/sys_account`, + which declares `apiEnabled: true, apiMethods: ['get','list']` — to an **admin + for every user's row**, and to a **member for their own** (the + `sys_account_self` RLS policy grants `select` on `user_id == current_user.id`). + + These are one-way hashes, not reversible outbound credentials — which is why + #7987 correctly refused to bundle them with the OAuth tokens. But a served + password hash is an offline-cracking target, and `previous_password_hashes` + multiplies it by the history ring while its own declaration says it is _never + exposed in UI_. This is the disposition #7728 already reached for + `sys_api_key.key`, which was **also** a stored hash and was still ruled unfit to + serialize through the API face. + + Neither credential collector could reach them: `collectMaskedReadFields` keys on + the field **TYPE** (`secret` / `password`) _and_ exempts objects declaring + `managedBy: 'better-auth'`, which this object is — while these columns are + `text` / `textarea`. Two independent barriers, both missing. + + **The fix is two declarations plus two recovery seams**, and the second seam is + the part a bare flag would have missed: + + - both columns are declared `internal: true` — the opt-in, type-independent flag + from #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's adapter readers** are recovered by the existing per-object + readback table, widened with `password`: the sign-in verifier compares against + the hash on the row `internalAdapter.findCredentialAccount(userId)` returns, + so the strip alone would break password sign-in for every user. + - **plugin-auth's own RAW-engine readers** are recovered by a new seam in the + same module, `recoverInternalFieldsForSystemRead`. This is the half that makes + the flag safe: the readback table is imported by exactly one file + (better-auth's storage adapter), so it cannot reach a caller that reads the + engine directly — and the engine's strip has **no `isSystem` carve-out** by + #7728's design. Measured against a real ObjectQL engine: the reuse ring's + `findOne` returns `{"id":"a1"}` for a query that names both columns in an + explicit projection under `context: { isSystem: true }`. + + Left unrecovered, `assertPasswordNotReused` would become a **silent no-op** — + its comparison list empties, the loop never runs, `PASSWORD_REUSE` is never + thrown, and its own `catch { return undefined }` means nothing announces it. + The ADR-0069 D1 control would report success while accepting every reused + password. Its unit tests would have stayed green throughout, because they use + fake engines that never apply the strip. + + **No ADR-0100 guard change, and none was needed.** `Engine.resolveInternalField` + has exactly one predicate — `internal === true` — so flagging the columns makes + them legitimately dereferenceable through the privileged accessor. The ADR-0100 + sentence in its refusal message is prose explaining why a _non-flagged_ field has + other channels, not a second predicate; the guard stays exactly as selective as + it was, and a non-flagged column on the same object is still refused with + `INVALID_FIELD` / 400. + + Regression proof drives both directions on a real booted stack: both columns are + absent for both personas — including a caller who spells them out in `?select=` — + while the values remain on disk and reachable through the privileged accessor, + password sign-in still works, and the reuse ring still grows across a password + change on every transport lane. + +- 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 [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e7bccaa] +- Updated dependencies [e43d63a] +- Updated dependencies [5047cb8] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [3ab2488] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b537855] +- Updated dependencies [b69d0f5] +- Updated dependencies [4dc8a61] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [e6e1de4] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [7fc01db] +- Updated dependencies [8bee54b] +- 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] +- Updated dependencies [d634e66] + - @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..9d86862919 100644 --- a/packages/plugins/plugin-dev/CHANGELOG.md +++ b/packages/plugins/plugin-dev/CHANGELOG.md @@ -1,5 +1,96 @@ # @objectstack/plugin-dev +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e7bccaa] +- Updated dependencies [e43d63a] +- Updated dependencies [5047cb8] +- Updated dependencies [a751f7d] +- Updated dependencies [cf0d902] +- Updated dependencies [498f4e8] +- Updated dependencies [cc5c07b] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [8656d67] +- Updated dependencies [3d61924] +- Updated dependencies [716ac9b] +- Updated dependencies [6feac91] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [4ea921c] +- Updated dependencies [3ab2488] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4e71ae1] +- Updated dependencies [20067c5] +- Updated dependencies [e783e16] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b537855] +- Updated dependencies [b69d0f5] +- Updated dependencies [4dc8a61] +- Updated dependencies [4d47afe] +- Updated dependencies [4fc4a3c] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [e6e1de4] +- Updated dependencies [3851f87] +- Updated dependencies [c73eacd] +- Updated dependencies [712e185] +- Updated dependencies [693c788] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [7fc01db] +- Updated dependencies [19db5fa] +- Updated dependencies [2b9d33a] +- Updated dependencies [8bee54b] +- Updated dependencies [04f8fdb] +- Updated dependencies [c25b2d5] +- 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] +- Updated dependencies [d634e66] + - @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..7ebf296d6c 100644 --- a/packages/plugins/plugin-email/CHANGELOG.md +++ b/packages/plugins/plugin-email/CHANGELOG.md @@ -1,5 +1,69 @@ # @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 [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- 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 [d634e66] + - @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..e763703dda 100644 --- a/packages/plugins/plugin-hono-server/CHANGELOG.md +++ b/packages/plugins/plugin-hono-server/CHANGELOG.md @@ -1,5 +1,53 @@ # @objectstack/plugin-hono-server +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] +- Updated dependencies [d634e66] + - @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..87b2a4ee1b 100644 --- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md +++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md @@ -1,5 +1,27 @@ # @objectstack/plugin-pinyin-search +## 17.1.0 + +### Patch Changes + +- Updated dependencies [e43d63a] +- Updated dependencies [a751f7d] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4e71ae1] +- 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..abcfc0c59c 100644 --- a/packages/plugins/plugin-reports/CHANGELOG.md +++ b/packages/plugins/plugin-reports/CHANGELOG.md @@ -1,5 +1,58 @@ # @objectstack/plugin-reports +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- 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 [d634e66] + - @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..43142c9241 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -1,5 +1,730 @@ # @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 those + three declarations, and no further. But confined is not unreachable: #8772 + _proposes_ a publish-time lint that would refuse them, and that issue is open + and unruled, so nothing refuses them at publish today. A `master_detail` with + no `required` draws only a non-blocking `warning`; `required` + `readonly` and + `required` + `system` draw nothing at all. An app can therefore newly declare + any of the three, publish cleanly, and still see the old + `422 MISSING_REQUIRED_FIELD` with no `fields[]` — so treat these shapes as a + live surface to avoid authoring into, not as a legacy tail that is already + closing. 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. + +- 3851f87: Partial field masking (#8993): `FieldSchema` declares `maskingRule` — a closed + preset enum (`phone`, `id_card`, `bank_account`, `email`, `name`) plus a + `{ keepHead, keepTail }` escape hatch — and plugin-security's `FieldMasker` + enforces it in the same PR (ADR-0049 declare = enforce; the key re-enters the + schema only with its runtime consumer attached, honouring the 2026-06 prune in + spirit). + + A field declaring a rule is served masked-but-recognisable (`138****5678`) to + every non-system caller; the field's `requiredPermissions` (ADR-0066 D3) is the + unmask gate — holders of all listed capabilities read the full value. A + permission set that marks the field non-readable still deletes it entirely. + Masking rides the single runtime channel, so API callers, browser users, the + CSV/XLSX export route and the AI-context interceptor all see the same + deterministic, length-preserving masked value. Masked callers cannot filter, + sort, group or aggregate on the field (403, the FLS predicate-oracle guard), + and a write that round-trips a masked placeholder is refused with + `400 VALIDATION_ERROR` instead of silently overwriting the stored value. + New exports: `FieldMaskingRuleSchema`, `FieldMaskingKeepSchema`, + `FIELD_MASKING_PRESETS`, `maskFieldValue`, `MASK_CHAR`. + +### 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. + +- 712e185: fix(security): platform default permission sets are stamped `managed_by: 'platform'`, so `os meta resync` stops skipping every one of them (#8692) + + + + `bootstrapPlatformAdmin` seeded the default permission sets + (`admin_full_access` / `member_default` / `viewer_readonly` …) **without writing + `managed_by`**, so the value fell to the declared `defaultValue: 'admin'` on + `sys_permission_set`. `os meta resync` only reconciles rows the platform still + owns (`managed_by` absent or `'platform'`), so the platform's own default sets + took the skip branch — **measured on a real engine: `resynced 0` / + `resyncSkipped 8`, every shipped set**, each one logged as an _"intentional + override"_ for a row no admin had ever touched. + + That is the exact inverse of what the resync flag was built for (#2705: + _"reconcile the row to the shipped dist so a dev source edit takes effect + without `--fresh`"_). The command could not perform, for the rows it names in + its own help text, the one job it exists to do. + + **The seed insert now stamps `managed_by: 'platform'` explicitly**, which also + puts this seeder in line with its two siblings in the same package — + `bootstrap-builtin-positions.ts` and `bootstrap-system-capabilities.ts` both + stamp `'platform'` rather than inheriting a default. A fresh install's default + sets are now platform-owned, and a resync reconciles all of them. Admin-takeover + protection is unchanged in shape and becomes _real_ rather than nominal: a set + an admin takes over in Setup is stamped `'admin'` by the projection path, so + platform-seeded and admin-authored rows finally carry **different** values + instead of the same one. + + **Forward-stamp only — existing rows are deliberately NOT migrated.** A stored + `'admin'` is indistinguishable between "the old seeder's field default" and "an + administrator took this set over in Setup". Restamping legacy rows to + `'platform'` would make genuine admin customizations reconcilable and could + silently overwrite them on the next `os meta resync`, so pre-existing rows keep + the skip permanently and by decision. Report, don't rewrite. A legacy install + that wants its platform defaults reconciled has to re-own the rows deliberately + (or re-seed with `--fresh`) — an operator's choice, not one a boot makes for + them. The seeder's docblock records this so the next reader finds a decision + rather than a mystery. + + **The skip warning stops claiming intent.** It read + `… row is admin-owned (intentional override)`; on any pre-existing install that + sentence is false, because the only writer may have been this same seeder one + call earlier. It now reads `… row is admin-owned` — provenance and action, no + claim about anybody's intent. + + Two comments asserting that the insert-once posture _"keeps the platform + defaults env-authored — the posture `bootstrapDeclaredPermissions` relies on"_ + are removed: that reliance was measured false. `bootstrapDeclaredPermissions` + special-cases only `managed_by === 'package'`; every other value — `'platform'` + included — falls to the same `skippedEnvAuthored` branch, so its behaviour is + identical before and after this change. + + The pin suite added by the measurement round now asserts both sides of the line + the ruling drew: a fresh install stores `'platform'` and resyncs everything, and + a pre-ruling `'admin'` row is still skipped with its content intact. + +- 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. + +- c25b2d5: fix(security): comment moderation stops being dead behind the platform delete floor — `sys_comment` gets the per-object delete policy that lets a parent-record editor moderate (#8839) + + + + `plugin-audit` implements an explicit **author-or-parent-editor** rule for + removing a comment — _"Rewriting or removing someone else's words is moderation, + hence the tighter author-or-parent-editor rule"_ — deriving a comment's access + from the record its `thread_id` names, the way an attachment's derives from its + parent. + + **That rule was unreachable in every org-bound deployment.** `member_default` + ships a wildcard row-level delete floor: + + ``` + { name: 'owner_only_deletes', object: '*', operation: 'delete', + using: 'created_by == current_user.id', positions: ['org_member'] } + ``` + + A parent-record editor moderating someone else's comment holds `org_member` and + is not the comment's `created_by`, so the floor answered `PERMISSION_DENIED` + before the moderation rule was ever consulted. The floor is a **second, + parent-blind implementation** of "who may remove this row", and on `sys_comment` + it was winning against the one authority that can actually see the parent. + + **Why nothing caught it:** the only fixture proving the capability + (`comments-permission-matrix.dogfood.test.ts` case (d)) booted **org-less**, so + its principals resolved `positions: ['everyone']`, the positions-gated floor never + applied, and the case passed over the broken behaviour — #8023's disarm shape. + + **The fix is one per-object policy** in `member_default`: + + ``` + { name: 'sys_comment_moderation', object: 'sys_comment', operation: 'delete', + using: 'id != null', positions: ['org_member'] } + ``` + + It contributes the **alternate match** that stops the floor pre-empting the gate; + it does not re-implement the rule. The parent-editor limb is not expressible as a + row predicate — the authority lives on another record and RLS has no join — so + `id != null` is every row of this object said plainly, the same spelling and + reasoning as the existing `sys_invitation_org_admin`. What actually narrows a + `sys_comment` delete is, in order: the object-level delete bit (this set grants no + `allowDelete` at all), Layer 0's tenant wall, and then plugin-audit's gate, which + requires every matched row to pass and fails closed on a thread naming no + authorizable parent. That gate is not optional — `AuditPlugin` registers + `sys_comment` and installs the gate in the same `start()`. + + ⛔ **The wildcard floor itself is unchanged.** The widening is scoped to + `sys_comment`, and to the `delete` limb only; the `update` half of plugin-audit's + rule deliberately stays under the floor. + + The `positions: ['org_member']` domain is load-bearing rather than cosmetic: it + confines the widening to exactly the principals the floor binds. An undomained + twin would carry a `using` into a delete class that is **empty** today for + org-less and `everyone`-only principals, switching off the derive-from-select rule + that currently bounds their writes to their readable set — widening them too. + + Access-widening approved by maintainer ruling (2026-08-15), which is what the + standing manual floor on relaxing an access-control boundary required. + + The pin is the fixture, now **armed**: `orgContext: true` plus `assertArmed` on + both the author and the moderator persona, so the file can never again certify + moderation from a boot structurally unable to observe the floor. Reverse-verified + — with the policy removed and the artifact rebuilt, exactly one case reddens with + `PERMISSION_DENIED` on `sys_comment` and the other nine stay green. The + stranger-without-parent-EDIT case now asserts its refusal code **exactly** + (`RECORD_NOT_ACCESSIBLE`, plugin-audit's gate — not the floor's + `PERMISSION_DENIED`), so the floor silently re-asserting itself over `sys_comment` + cannot pass as a correct refusal. + +- 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 [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [845e164] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [7fc01db] +- Updated dependencies [8bee54b] +- 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 [d634e66] + - @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..1f7c4d7d0c 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -1,5 +1,259 @@ # @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"]] + ``` + +- f8537df: A write refused because a federated object's `owner_id` is the platform's phantom anchor now says so, once per object (#8418). + + **No verdict changes.** `checkEdit` / `checkDelete` stay fail-closed exactly as shipped — this adds a diagnostic and nothing else. Maintainer ruling 2026-08-13 (option C on #8418): keep `deny`, make the refusal visible. + + What was wrong: on an ADR-0015 federated object with no author-declared `owner_id`, the registry injects the anchor but the platform provisions no column behind it, so the ownership fast path selects `owner_id` off a remote table that does not have it. The SQL driver's recovery ladder DISCARDS a projection naming an unresolvable column and re-runs `select('*')` instead of raising — so `matchesOwnerScope` receives a good row that simply has no `owner_id` key, reads `owner == null`, and refuses. Because nothing threw, `writeGateFailClosed` was never reached and **nothing was logged anywhere**: the operator got a bare 403 with no trace, at every write depth (`org` included — the null-owner short-circuit runs before the scope is consulted). Only a `modifyAllRecords` holder could still write. + + `SharingService` now emits `PHANTOM_ANCHOR_WRITE_DENY_NOTICE` at `warn` on that path, naming the object, the owner field and the caller, with both remedies in the wording: declare the real remote owner column, or move the object off an owner-scoped sharing model. The constant is exported so a deployment can match on it. + + Deduped **per object**, for the service's lifetime. The condition is a property of the registered schema, identical for every row and every caller, so a bulk write emits one line rather than one per row and one misconfiguration is not multiplied by the principal count. + + It fires only on the phantom anchor, never on an ordinary owner-less row: the discrimination is `hasPhantomOwnerAnchor` provenance (is this `owner_id` the platform's injected constant, or a column the author declared?), not `owner == null` and not an `external` test. A federated object with a real declared remote owner column keeps scoping normally and stays silent. + + The diagnostic cannot cost a write — it returns `void`, its caller ignores it, and a throwing logger is swallowed, so no ordering of schema lookup, latch and logger can move a verdict. + + Also corrected in passing: this package attributed the driver's non-throwing unknown-column recovery to **SQLite specifically**. That understated it — the projection rung is gated by the driver's single shared `isUnresolvableColumnError` predicate, which spells all three dialects it speaks (`no such column`, `column … does not exist`, and since #8926 `Unknown column '…'`), so the silent refusal reproduced on every supported dialect. Wording only; no driver change. + +### 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 [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [a751f7d] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4e71ae1] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [845e164] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [7fc01db] +- Updated dependencies [8bee54b] +- 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] +- Updated dependencies [d634e66] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/objectql@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/metadata-core@17.1.0 + - @objectstack/formula@17.1.0 + ## 17.0.0 ### Major Changes @@ -1800,10 +2054,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..a05ab471de 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -1,5 +1,122 @@ # @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 [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..b1f7fa5340 100644 --- a/packages/qa/dogfood/CHANGELOG.md +++ b/packages/qa/dogfood/CHANGELOG.md @@ -1,5 +1,126 @@ # @objectstack/dogfood +## 0.0.41 + +### Patch Changes + +- 2ce1eb4: docs(qa): narrow the ADR-0056 D10 authz conformance matrix's advertised completeness claim to what its ratchet actually checks (#8711) + + The matrix header and its companion test's header previously read as though + a new declared-but-unenforced authorization primitive would "break CI." It + would not, for most of the ledger: the completeness `discover()` ratchets is + over a **curated table of HTTP/transport entry points** (15 probes over 11 + named source files), not over primitives. A primitive enforced by a predicate + inside an existing resolver — the `sys_permission_set.active` / + `sys_position.active` rows added in #8812 are the normal case, not an + exception — adds no entry point, so it can be neither UNCLASSIFIED nor STALE. + + Both headers now say so explicitly, carrying the measured numbers so the + narrowed claim is load-bearing rather than vague: 43 of the matrix's 50 rows + carry no `covers` key at all, 37 of the 43 `enforced` rows are exactly that + in-resolver shape, and — preserved, because it is real — 5 of the file's 9 + `covers` keys are gate-pins that vanish (and fail CI) when the guard call + they name is deleted. Prose and comments only; nothing about the ratchet's + checking behaviour, the `discover()` table, or any row changes. Maintainer + ruling on #8711 (Option A): narrow the claim, do not build a + primitive-discovery ratchet (measured unachievable in general form). + +- Updated dependencies [c9f5950] +- Updated dependencies [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [1408fe3] +- Updated dependencies [a751f7d] +- Updated dependencies [cf0d902] +- Updated dependencies [498f4e8] +- Updated dependencies [cc5c07b] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [04d03c3] +- Updated dependencies [8656d67] +- Updated dependencies [716ac9b] +- Updated dependencies [6feac91] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [4ea921c] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [b705a6c] +- Updated dependencies [f8eb736] +- Updated dependencies [4e71ae1] +- Updated dependencies [20067c5] +- Updated dependencies [d09d0fd] +- Updated dependencies [ff4ba6a] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [c73eacd] +- Updated dependencies [f8537df] +- Updated dependencies [712e185] +- Updated dependencies [693c788] +- Updated dependencies [845e164] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [7fc01db] +- Updated dependencies [b53d38e] +- Updated dependencies [8bee54b] +- Updated dependencies [04f8fdb] +- Updated dependencies [c25b2d5] +- 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 [d634e66] +- 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/objectql@17.1.0 + - @objectstack/plugin-sharing@17.1.0 + - @objectstack/types@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/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..706889b857 100644 --- a/packages/qa/downstream-contract/CHANGELOG.md +++ b/packages/qa/downstream-contract/CHANGELOG.md @@ -1,5 +1,43 @@ # @objectstack/downstream-contract +## 0.0.39 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..5a91349a9d 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -1,5 +1,428 @@ # @objectstack/rest +## 17.1.0 + +### Minor Changes + +- b537855: fix(rest): `POST /meta/:type/:name/publish` and `.../rollback` require the `manage_metadata` capability (#8919) + + + + **BREAKING for any integration that publishes or rolls back metadata with a + principal holding no authoring capability.** Landing after the v17.0.0 cut, so + it ships as `minor` under the lockstep launch-window convention. + + `packages/rest` gates four metadata-authoring doors on ADR-0066 D1's + `manage_metadata` capability — `POST /meta/_migrate-stored`, `PUT /meta/:type/:name` + (#6603), `PUT /meta/:type/:section/:name` and `DELETE /meta/:type/:name` (#7019). + The two **promotion** verbs did not, and promotion is what decides which body is + live: `publishMetaItem` flips the `sys_metadata` row `state: 'draft'` to + `'active'` (ADR-0027 (E)(5) defines sealing a publish as exactly that flip), and + `rollbackMetaItem` restores a caller-supplied `toVersion` as the new live row. + + **Measured through a composed host, down to the protocol layer, before the fix:** + + | principal | publish | rollback | + | :-------------------------------------- | :------------------------ | :------------------------ | + | anonymous | 401, protocol not reached | 401, protocol not reached | + | authenticated, **no** `manage_metadata` | **200, protocol reached** | **200, protocol reached** | + | authenticated, `manage_metadata` | 200, protocol reached | 200, protocol reached | + + So the reachable cohort was every authenticated principal holding no authoring + capability at all: it could take a draft somebody else authored and make it + live, or restore any historical version over the live row. Anonymous callers + were already refused by the `/meta` umbrella (`registerMetadataEndpoints`), so + what these gates add is precisely the authenticated-but-uncapable cohort. + + **`rollback` is the sharper of the two.** The caller supplies `toVersion`, which + makes it a mechanism for reverting security hardening — a permission set as it + stood before it was tightened, a validation rule from before it existed, a + layout from before field-level security. It is also the door with the least + behind it: publish at least re-runs `assertRuntimeAuthoringRules` on the + promoted draft (#4463 D1), while rollback runs no content gate at all. Neither + of those reads the caller in any case — D1 answers "is this metadata valid", not + "may you press this button" — so nothing downstream was ever doing this job. + Audit rows are still written either way, so the action remains traceable after + the fact. + + **No legitimate caller loses anything, and that is measured rather than + assumed.** The Studio designer's save-then-publish loop saves `?mode=draft` and + then POSTs `/publish`, and its **first** step already demanded + `manage_metadata` — so every principal that can author a draft already clears + the new gate. The shipped sets bear this out: `admin_full_access` (the only set + carrying `studio.access`) carries `manage_metadata` too, while + `organization_admin` and `member_default` are refused at the save door **today**. + The only callers the gap benefited were exactly the ones already refused the + authoring door — able to promote a draft they could not have written. + + **Migration — grant `manage_metadata` to any service principal that publishes.** + An integration that promotes metadata on its own schedule (a CI job sealing a + release, an AI authoring agent) needs the capability explicitly; there is no + automatic replacement, deliberately. `isSystem` contexts bypass, as on every + other capability gate on the platform, so in-process callers are unaffected. + + The gate is the sibling doors' four lines verbatim, deliberately not a second + way of demanding the same capability, and it fires **before** the protocol is + resolved so 403-vs-501 leaks no kernel capability and nothing is promoted before + the refusal. + + ⚠️ **An author/publisher capability split is NOT introduced here.** Separating + "may write a draft" from "may make it live" is a defensible design, but it needs + a _different_ declared capability and is a product decision; both defensible + designs require a gate, and the state this fixes was neither. + + Ships with an **enumeration pin** rather than two assertions. The defect was not + that two handlers forgot a gate — it was that the gate was a convention held by + repetition and nothing else, so the next metadata write door had a one-in-three + chance of copying an ungated neighbour with no test going red. The new suite + derives the write doors from the composed server's own route table and compares + them against a declared list, so a new mutating `/meta` route fails the build + until it is enumerated and its refusal asserted. + +- 4dc8a61: **Audit attribution change — the recorded actor on `/meta` writes is now the authenticated identity, and `X-Actor` is ignored.** All five `/meta` write sites (save, delete/reset, publish, rollback, compound save) stamp `sys_metadata_audit.actor` and `sys_metadata_history.recorded_by` with the identity the request was actually authorized as. A request that sends `X-Actor` is recorded against its own authenticated caller, not the header's value. Maintainer ruling 2026-08-12 on #7941, re-confirmed 2026-08-15. + + Why: the header used to outrank the authenticated identity. That ordering was inert for as long as the other limb produced nothing — `req.user` / `req.userId` are never set on this transport — so nothing depended on it. Fixing that producer (#7749) made the precedence load-bearing for the first time, and what it then meant was that any caller already holding `manage_metadata` could sign somebody else's name to a metadata write: the compliance trail answered "who _claimed_ to change this" rather than "who changed this", which is the question #7749 was filed to make answerable. Attribution now cannot drift from authorization, because both read the same `resolveExecCtx` the route's own capability gate reads. + + The header limb is **removed rather than reordered**. The ruling permitted keeping it for genuine machine/system callers with no authenticated user, but only if a consumer census showed that shape exists — it does not, so a caller cannot choose the recorded name in any shape, including on the machine-write path where there is no identity for the header to lose to. + + Deliberately unchanged: + + - **Real impersonation still attributes correctly.** The platform's impersonation is session-level (better-auth admin plugin, `sys_session.impersonated_by`), so `resolveExecCtx` already resolves to the impersonated user and their metadata writes are recorded against them. Nothing in that path went through `X-Actor`. + - **Machine and anonymous writes.** No resolved principal still means no actor, so the protocol's own `'system'` / `NULL` defaults apply exactly as before — a machine write is never stamped with a real user. + - **Sending `X-Actor` is not an error.** It is ignored, not rejected; no request that succeeds today starts failing. + + Who is affected: any caller that relied on `X-Actor` to attribute a `/meta` write to somebody other than itself. The census over `objectstack` and `objectui` found no such caller — `objectui`'s `MetadataClient` can send the header through an optional `options.actor`, but nothing in that repo ever passes one, leaving that option inert against this server. + +### 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 [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [e6e1de4] +- Updated dependencies [3851f87] +- Updated dependencies [845e164] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [7fc01db] +- Updated dependencies [8bee54b] +- 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] +- Updated dependencies [d634e66] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/service-package@17.1.0 + - @objectstack/metadata-core@17.1.0 + - @objectstack/observability@17.1.0 + ## 17.0.0 ### Major Changes @@ -261,8 +684,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 +2391,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 +6552,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..c2ec5f568b 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,451 @@ # @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 [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e7bccaa] +- Updated dependencies [e43d63a] +- Updated dependencies [5047cb8] +- Updated dependencies [a751f7d] +- Updated dependencies [cf0d902] +- Updated dependencies [498f4e8] +- Updated dependencies [cc5c07b] +- Updated dependencies [13d7864] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [3508678] +- Updated dependencies [d491625] +- Updated dependencies [8656d67] +- Updated dependencies [177442d] +- Updated dependencies [950bd94] +- Updated dependencies [3043e98] +- Updated dependencies [9c4d096] +- Updated dependencies [716ac9b] +- Updated dependencies [7b3c033] +- Updated dependencies [6feac91] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [4ea921c] +- Updated dependencies [3ab2488] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [fd6bdf8] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [a9df51c] +- Updated dependencies [f8eb736] +- Updated dependencies [ab8b10f] +- Updated dependencies [4e71ae1] +- Updated dependencies [20067c5] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b537855] +- Updated dependencies [ead96d0] +- Updated dependencies [b69d0f5] +- Updated dependencies [4dc8a61] +- Updated dependencies [c15eb23] +- Updated dependencies [4d47afe] +- Updated dependencies [b740440] +- Updated dependencies [90a12fb] +- Updated dependencies [72050cc] +- Updated dependencies [d70428a] +- Updated dependencies [c8806ae] +- Updated dependencies [bb96297] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [e6e1de4] +- Updated dependencies [3851f87] +- Updated dependencies [c73eacd] +- Updated dependencies [712e185] +- Updated dependencies [693c788] +- Updated dependencies [0961065] +- Updated dependencies [845e164] +- Updated dependencies [8d017eb] +- Updated dependencies [1a7f907] +- Updated dependencies [4e3a4c3] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [7fc01db] +- Updated dependencies [88ef34d] +- Updated dependencies [add2d19] +- Updated dependencies [2b9d33a] +- Updated dependencies [8bee54b] +- Updated dependencies [04f8fdb] +- Updated dependencies [c25b2d5] +- Updated dependencies [147eadc] +- Updated dependencies [0f59584] +- Updated dependencies [ff08691] +- Updated dependencies [159e299] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [7c2f386] +- Updated dependencies [d5156b9] +- 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] +- Updated dependencies [d634e66] + - @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/objectql@17.1.0 + - @objectstack/service-datasource@17.1.0 + - @objectstack/driver-sql@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/metadata-core@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..04d6140789 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -1,5 +1,102 @@ # 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 [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] +- Updated dependencies [d634e66] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/types@17.1.0 + ## 17.0.0 ### Major Changes @@ -1051,7 +1148,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 +4869,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..ba86551098 100644 --- a/packages/services/service-automation/CHANGELOG.md +++ b/packages/services/service-automation/CHANGELOG.md @@ -1,5 +1,157 @@ # @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 [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..48e8a01d1a 100644 --- a/packages/services/service-cache/CHANGELOG.md +++ b/packages/services/service-cache/CHANGELOG.md @@ -1,5 +1,50 @@ # @objectstack/service-cache +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..cfa982feca 100644 --- a/packages/services/service-cluster-redis/CHANGELOG.md +++ b/packages/services/service-cluster-redis/CHANGELOG.md @@ -1,5 +1,44 @@ # @objectstack/service-cluster-redis +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..856fa46862 100644 --- a/packages/services/service-cluster/CHANGELOG.md +++ b/packages/services/service-cluster/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/service-cluster +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..3134c55d77 100644 --- a/packages/services/service-datasource/CHANGELOG.md +++ b/packages/services/service-datasource/CHANGELOG.md @@ -1,5 +1,514 @@ # @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 + +- 2420641: feat(spec): refuse a credential in the mongo options passthrough (`config.options.auth.password`) at publish (#9040) + + **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 FOURTH spelling of the same inline secret: #7990 refused the top-level + `password` key, #8082 the URL userinfo (`user:password@host`), #8337 the + credential-bearing URL query parameters — and the MongoClient `options` + passthrough stayed open one syntax over. + `options: { auth: { username, password } }` parsed green, persisted the + password cleartext into `sys_metadata` (served back by the ordinary data API, + unredacted), and genuinely authenticated: measured on `mongodb@7.5.0`, the + client the driver spreads `config.options` into, the block is transformed into + `MongoCredentials` — so the workaround was live, not inert. + + **What is refused** (write door, closed measured list + `MONGO_OPTIONS_CREDENTIAL_PATHS` behind `credentialFreeMongoOptions`, composed + with the #8336 placeholder refusal on the same slot): a NON-EMPTY STRING + `options.auth.password`, with the binder prescription — and the "wins over" + reassurance is true for this syntax: a bound `external.credentialsRef` secret + outranks the passthrough `auth` block at connect (#8696, measured). + Deliberately not refused, each measured: `auth.username` alone (#8876's + asymmetry — a username is not credential material), an empty password (the + passthrough twin of `user:@host`), every legitimate passthrough option + (`replicaSet`, `tls`, timeouts — byte-identical pins), + `authMechanismProperties.AWS_SESSION_TOKEN` (the v7 client itself throws on it + under MONGODB-AWS and nothing reads it otherwise), and the binder-slotless + client secrets (`proxyPassword`, `tlsCertificateKeyFilePassword`, `key`, + `passphrase`) — refusing those would name a remedy that does not exist (the + binder fills exactly one slot; the turso-`encryptionKey` posture, #8081 + item 4). + + **Read half** (additive, never the substitute — #8082's ruling): stored + passthrough secrets are now redacted on every read exit — + `options.auth.password` plus the binder-slotless names above and + `AWS_SESSION_TOKEN` — reported as dotted `redactedKeys` + (`options.auth.password`), which the metadata write door's generic + carry-forward already walks, so an untouched "Save" keeps the stored + credential on both admin doors (`restoreRedactedConfig` mirrors per leaf). + The #8155 credential-migration planner refuses a stored passthrough-credential + row with the per-row remedy instead of planning `nothing-to-migrate` over live + cleartext (dropping only the nested leaf would leave an `auth` block the + client refuses at construction, measured). + + ## FROM → TO + + ```yaml + # before — parsed green; password stored cleartext in sys_metadata and + # resolved into MongoCredentials at connect + driver: mongodb + config: + url: mongodb://app@mongo.internal:27017/events + options: + replicaSet: rs0 + auth: { username: app, password: PLAINTEXT-IN-METADATA } + + # after — rejected with the binder prescription; bind the secret instead + driver: mongodb + config: + url: mongodb://app@mongo.internal:27017/events + options: + replicaSet: rs0 + 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 — and auto-dropping only the nested password + would leave an `auth` block the MongoDB client refuses outright. + + + +- 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. + + + +- 90a12fb: fix(security): a mongo datasource that binds `external.credentialsRef` and authors a connection URL now connects with the bound credential instead of none (#8696) + + + + `buildMongoUrl`'s DSN branch returned the authored `config.url` verbatim and + applied `spec.secret` nowhere. A mongo datasource that bound its secret through + `external.credentialsRef` (or the connection form's secret field) therefore + connected with **whatever the URL itself carried** — which, since #8082 refuses + a `user:password@` userinfo at the publish door, is **no credential at all**. + Measured on `origin/main` @ `792524c22`, mongodb 7.5.0: + + ```text + config.url 'mongodb://app@db.internal:27017/app' + a bound secret + -> MongoClient credentials {username:'app', password:''} + ``` + + The connect path is fail-closed on a ref it cannot resolve, so an operator + reasonably reads "the datasource connected" as "the bound credential was used". + It was not: the credential was declared, resolved, injected into the factory — + and then dropped at the last call site with no diagnostic. That is + declared-≠-enforced (Prime Directive #10) one layer below the spec, and + `MongoConfigSchema.url` is the contract it broke, verbatim: _"bind the secret + (`external.credentialsRef` / the connection form's secret field) and **it is + injected at connect time**. A bare username (`user@host1`) stays writable."_ + The arm's behaviour was decided by whether the operator happened to author a + URL — the composed branch five lines below had honoured the secret since #4410. + This closes the last arm of the family #7314 / #7385 / #8152 / #8875 have each + closed one driver at a time. + + **The fix injects `options.auth` beside an unmodified url — it does not rewrite + the URL.** Measured on mongodb 7.5.0 (the `MongoClient` constructor resolves + credentials eagerly, so all of it is assertable with no server): + + ```text + 'mongodb://app@db.internal:27017/app' + auth{app,BOUND} -> password BOUND + 'mongodb://app:embedded-legacy@h/app' + auth{app,BOUND} -> password BOUND + 'mongodb://app@h1:27017,h2:27017/app' + auth{app,BOUND} -> password BOUND + 'mongodb+srv://app@c0.example.net/app' + auth{app,BOUND} -> password BOUND + 'mongodb://app@h/app?authSource=admin' + auth{app,BOUND} -> source admin + ``` + + So the authored URL is handed over byte for byte, no second dialect of + `mongodb://…` enters this repo, the multi-host and `+srv` forms ride through + unharmed, and a bound secret **wins** over a legacy password embedded in a + stored pre-#8082 row — the same precedence the mysql arm states, reached by a + different mechanism because the clients merge in opposite directions. The + userinfo **username** `auth` also requires is read through the platform's own + DSN grammar (`urlUserinfoUsername`, #8876) and percent-decoded at the call + site: `new URL()` cannot even parse the multi-host form this schema documents, + and a second hand-rolled copy of those boundaries is the shape #8082's ruling + rejects by name. + + **A URL that names no user gets nothing, deliberately.** `auth` is not + constructible from a password alone, and inventing an empty username is + measurably worse than silence: `mongodb://db.internal:27017/app` carries no + credentials at all today, and would carry `{username:''}` — a guaranteed + handshake failure — if the arm injected regardless. Injection happens only + where the URL already declares authenticated intent, which is also exactly what + the composed branch has always done with the same input. Making that + contradictory pair (a bound `credentialsRef` beside a user-less URL) loud + belongs at the authoring door, where both halves are visible at once; it is + filed rather than guessed at here. + + **Blast radius is exactly the broken class.** A datasource that binds no secret + reaches the client byte-for-byte as before, and the `options` passthrough keeps + arriving verbatim — the injected `auth` is merged into it, not assigned over + it. + + The pin extends `__tests__/bound-secret-dsn-branches.test.ts` (the mysql half's + file) and asserts at the **client-construction seam**: every mongo assertion + reads `MongoClient`'s own resolved `credentials`, never the URL string the + factory built. That distinction is load-bearing — a test asserting + `buildMongoUrl`'s return value would have passed throughout this defect's life, + and the postgres arm passes the equivalent config-layer assertion while still + being broken one layer lower. + +- 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. + +- d70428a: A mysql datasource that declares TLS now gets it, on both branches of the arm and in the spelling `mysql2` can read (#8874). + + Two defects with one cause — `buildMysqlConnection` resolved the TLS option and then handed it to a client that could not use it, or to nobody at all. + + **A declared `ssl` was dropped on the DSN branch.** With a `config.url` present the arm returned before the resolved option could be attached, so a datasource that declared TLS **and** wrote a connection url negotiated none — declared, resolved, dropped, with no diagnostic — while the discrete-fields branch of the same arm carried it. Whether a connection was encrypted therefore depended on which branch of one arm the datasource happened to take. The postgres arm has honoured this case since #4410 with its reasoning written in-code, and the same argument holds here: `mysql2` reads a uri and the `ssl` option as separate channels, and keeps the explicit key. + + **`ssl: true` was never a `mysql2` value.** Measured on mysql2 3.23.1, `new ConnectionConfig({ …, ssl: true })` throws `SSL profile must be an object, instead it's a boolean` — and `true` is exactly what a declared `ssl: { enabled: true }` with no certificate material resolves to, as does the `config.ssl` shorthand, whose schema is a boolean and so has no other authorable value. The branch that appeared to honour the declaration was therefore throwing on every connection acquisition for the commonest way of writing it. The resolved `true` is now translated to the empty-options object it is already documented to be short for (`{}`, which mysql2 normalises to `{ rejectUnauthorized: true }` — its own default for an object, not a verification policy chosen here). Certificate objects, `false`, and a stored profile name pass through untouched. + + **What does not change.** The DSN branch returns an object instead of the bare connection string **only when a declared `ssl` actually resolved** (or a secret is bound, unchanged from #8696). A datasource that declared neither still gets the byte-identical string knex has always parsed for it. Where the switch does happen, knex's own parse of the string and mysql2's parse of the same value as `uri` were compared key-by-key (`host`/`port`/`user`/`password`/`database`/`charset`/`timezone`/`connectTimeout`/`flags`/`socketPath`/`multipleStatements`) across the bare-username, embedded-password, no-userinfo, portless, percent-encoded-username and query-parameter forms — identical in every case, and pinned as a test rather than measured once. + + Nothing that declared no TLS moves, so the behaviour change is confined to the datasources that were already broken: the ones connecting in cleartext against their own metadata, and the ones that could not connect at all. + +- 0961065: fix(security): a bound `external.credentialsRef` reaches the postgres SERVER on the DSN branch, not just the knex config (#8873) + + A postgres datasource whose `config.url` is a DSN and whose credential is bound + through `external.credentialsRef` (or the connection form's secret field) opened + its connection **with no password at all**. Not a disclosure — a broken binding, + of the fail-quietly kind: `DatasourceConnectionService` resolved the secret + fail-closed, the operator saw a bound credential and a datasource reporting + connected, and the handshake carried nothing. + + **This arm was the one that looked correct.** It had an explicit secret branch + and a comment declaring the intent — _"For a DSN, a separately-supplied secret + overrides the embedded password"_ — and it emitted + `{ connectionString: url, password: spec.secret }`, which passes any assertion + written against the factory's own output. `pg` discarded the credential one + layer lower: + + ```js + // pg 8.22.0, lib/connection-parameters.js + if (config.connectionString) { + config = Object.assign({}, config, parse(config.connectionString)); + } + ``` + + Two independent mechanisms destroyed it, either sufficient on its own. `parse()` + emits a `password` key for **every** url — `''` when the url carries no userinfo + password — and `Object.assign` copies that over the injected value, after which + `val('password', …)` falls through to `PGPASSWORD` and the defaults; and knex's + `setHiddenProperty` has already made `password` a non-enumerable own property of + `connectionSettings`, which `Object.assign` does not copy at all. Measured on pg + 8.22.0 + knex 3.3.0: `postgresql://app@db.internal:5432/app` with a secret bound + resolved to password `null`, and a stored pre-#8082 url embedding + `app:embedded-legacy@` resolved to `'embedded-legacy'` — the DSN beating the + credential an operator deliberately bound. Since #8082 refuses a + `user:password@` userinfo at the publish door, the credential-free DSN is the + only authorable URL shape for this driver, so this was the shape the connection + form produces. + + **The remedy is a third shape, not either sibling's.** The clients merge a DSN + against explicit keys in opposite directions: `mysql2` lets the explicit key win + (`{ uri, password }`, #8875) and mongodb rides in `options.auth` beside an + untouched url (#9042), while `pg` lets the DSN win. So on the postgres DSN + branch — and only when a secret is bound — `connectionString` is gone: the arm + hands `pg` **pg's own parse of the url** (`pg-connection-string`, the client's + parser, so there is no second dialect of `postgresql://…` in this repo to drift + out of agreement) with the credential applied afterwards, where nothing + re-parses over it. Everything else resolves exactly as before, verified + key-by-key across the sslmode, unix-socket, `?options=`, credential-free, + embedded-password and no-userinfo forms. + + The competing remedy — keep `connectionString` and splice the secret into the + userinfo — was measured and rejected on two counts: `pg-connection-string` + honours a `?password=` query parameter **over** userinfo, so a stored pre-#8337 + row would still lose the bound secret; and it would materialise the cleartext + credential into a string nothing hides (`JSON.stringify` of knex's + `connectionSettings` prints the whole DSN, while a discrete `password` stays + hidden), re-creating at connect time the hardest-to-redact credential spelling + that #8082 refuses to let anyone author. + + **What changes for an existing deployment.** A DSN datasource that binds no + secret is byte-for-byte unaffected — it still hands `pg` the url unparsed. One + behaviour worth knowing: a stored pre-#8082 row that embeds a password in its + url _and_ binds a credential now authenticates with the **bound** credential, + which is the precedence this arm's own comment always claimed and both sibling + arms already apply. A DSN naming no user still receives the credential (unlike + the mongodb arm's deliberate no-op there): `pg` sends a password only when the + server asks for one, so injecting cannot break a datasource that connects today. + Finally, a url `pg`'s own parser rejects (a multi-host DSN, which node-postgres + does not implement) is now refused when the driver is built rather than on first + query — the same error, named and located, with the url deliberately not echoed + because it may itself embed a credential. + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] +- Updated dependencies [d634e66] + - @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 560fe8b502..27e57a4098 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..3293e0ac25 100644 --- a/packages/services/service-i18n/CHANGELOG.md +++ b/packages/services/service-i18n/CHANGELOG.md @@ -1,5 +1,52 @@ # @objectstack/service-i18n +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] +- Updated dependencies [d634e66] + - @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..0ca287e3a8 100644 --- a/packages/services/service-job/CHANGELOG.md +++ b/packages/services/service-job/CHANGELOG.md @@ -1,5 +1,58 @@ # @objectstack/service-job +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- 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 [d634e66] + - @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..b5f7b821f0 100644 --- a/packages/services/service-knowledge/CHANGELOG.md +++ b/packages/services/service-knowledge/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/service-knowledge +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..95dc964cac 100644 --- a/packages/services/service-messaging/CHANGELOG.md +++ b/packages/services/service-messaging/CHANGELOG.md @@ -1,5 +1,61 @@ # @objectstack/service-messaging +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- 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] +- Updated dependencies [d634e66] + - @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..670c50572c 100644 --- a/packages/services/service-package/CHANGELOG.md +++ b/packages/services/service-package/CHANGELOG.md @@ -1,5 +1,103 @@ # @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 [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [845e164] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [7fc01db] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..69a7994556 100644 --- a/packages/services/service-queue/CHANGELOG.md +++ b/packages/services/service-queue/CHANGELOG.md @@ -1,5 +1,58 @@ # @objectstack/service-queue +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- 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 [d634e66] + - @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..fe1605cb62 100644 --- a/packages/services/service-realtime/CHANGELOG.md +++ b/packages/services/service-realtime/CHANGELOG.md @@ -1,5 +1,58 @@ # @objectstack/service-realtime +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- 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 [d634e66] + - @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..990f0c72a8 100644 --- a/packages/services/service-settings/CHANGELOG.md +++ b/packages/services/service-settings/CHANGELOG.md @@ -1,5 +1,103 @@ # @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 [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- 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] +- Updated dependencies [d634e66] + - @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..9a4727eadd 100644 --- a/packages/services/service-sms/CHANGELOG.md +++ b/packages/services/service-sms/CHANGELOG.md @@ -1,5 +1,53 @@ # @objectstack/service-sms +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [04f8fdb] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..a2508d9555 100644 --- a/packages/services/service-storage/CHANGELOG.md +++ b/packages/services/service-storage/CHANGELOG.md @@ -1,5 +1,62 @@ # @objectstack/service-storage +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- 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] +- Updated dependencies [d634e66] + - @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..21d391f077 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,1815 @@ # @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). + + + +- 8640fb2: `os validate`: a dashboard header `modal` action's target resolves against declared PAGES, only (#9013) + + `validateDashboardActionRefs` resolved an `actionType: 'modal'` header button's + `actionUrl` the way objectui's `DashboardView` used to dispatch it: a defined + action name, a bare object name, or the `_` prefix form + (`create_`/`new_`/`add_`/`edit_`/`update_` + a defined object) all passed, and a + target naming a declared page ERRORED unless it collided with one of those. + + That mirror is gone. Maintainer ruling objectstack#6739-A (2026-08-09): a + `type: 'modal'` string target names a PAGE, only — the spec TSDoc, the published + docs and `defineStack`'s cross-reference walk already said so, and objectui#4764 + / objectui#4782 retired the renderer's object fallback and `DashboardView`'s + second copy of the prefix convention (enumerated across both repos' corpora: + zero producers). After that, `os validate` blessed exactly the buttons the + runtime refuses — the false affordance the rule exists to eliminate — while + refusing the one shape the runtime serves. + + **BREAKING** accept-set change on the `os validate` gating tier (landing after + the v17.0.0 cut; the lockstep launch-window convention ships it as `minor`): + + - A `modal` header target naming a defined action, a bare object, or a + `_` form now **fails** validation. Those buttons already + dispatch to a named refusal at runtime. + - A `modal` header target naming a declared page now **passes** — it was + wrongly refused before. + + ## FROM → TO + + ```ts + // before — passed validation; the runtime now refuses the click + header: { + actions: [{ label: 'New Deal', actionType: 'modal', actionUrl: 'create_opportunity' }], + } + + // after — name a declared page… + header: { + actions: [{ label: 'Intake', actionType: 'modal', actionUrl: 'deal_intake' }], // pages: [{ name: 'deal_intake' }] + } + // …or, to open an object's form, use the validated first-class shape + header: { + actions: [{ label: 'New Deal', actionType: 'form', actionUrl: 'opportunity.edit' }], + } + ``` + + There is deliberately no automatic rewrite: a retired-shape target is a + name-shaped guess (`create_opportunity` names the page `create_opportunity`, or + it names nothing — the ruling explicitly declined keeping the prefix), and only + the author knows whether the button meant a page or an object form. + `objectstack migrate meta` surfaces the change as a structured TODO (semantic + entry `dashboard-header-modal-target-page-only`, protocol major 18). + + + +- 2420641: feat(spec): refuse a credential in the mongo options passthrough (`config.options.auth.password`) at publish (#9040) + + **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 FOURTH spelling of the same inline secret: #7990 refused the top-level + `password` key, #8082 the URL userinfo (`user:password@host`), #8337 the + credential-bearing URL query parameters — and the MongoClient `options` + passthrough stayed open one syntax over. + `options: { auth: { username, password } }` parsed green, persisted the + password cleartext into `sys_metadata` (served back by the ordinary data API, + unredacted), and genuinely authenticated: measured on `mongodb@7.5.0`, the + client the driver spreads `config.options` into, the block is transformed into + `MongoCredentials` — so the workaround was live, not inert. + + **What is refused** (write door, closed measured list + `MONGO_OPTIONS_CREDENTIAL_PATHS` behind `credentialFreeMongoOptions`, composed + with the #8336 placeholder refusal on the same slot): a NON-EMPTY STRING + `options.auth.password`, with the binder prescription — and the "wins over" + reassurance is true for this syntax: a bound `external.credentialsRef` secret + outranks the passthrough `auth` block at connect (#8696, measured). + Deliberately not refused, each measured: `auth.username` alone (#8876's + asymmetry — a username is not credential material), an empty password (the + passthrough twin of `user:@host`), every legitimate passthrough option + (`replicaSet`, `tls`, timeouts — byte-identical pins), + `authMechanismProperties.AWS_SESSION_TOKEN` (the v7 client itself throws on it + under MONGODB-AWS and nothing reads it otherwise), and the binder-slotless + client secrets (`proxyPassword`, `tlsCertificateKeyFilePassword`, `key`, + `passphrase`) — refusing those would name a remedy that does not exist (the + binder fills exactly one slot; the turso-`encryptionKey` posture, #8081 + item 4). + + **Read half** (additive, never the substitute — #8082's ruling): stored + passthrough secrets are now redacted on every read exit — + `options.auth.password` plus the binder-slotless names above and + `AWS_SESSION_TOKEN` — reported as dotted `redactedKeys` + (`options.auth.password`), which the metadata write door's generic + carry-forward already walks, so an untouched "Save" keeps the stored + credential on both admin doors (`restoreRedactedConfig` mirrors per leaf). + The #8155 credential-migration planner refuses a stored passthrough-credential + row with the per-row remedy instead of planning `nothing-to-migrate` over live + cleartext (dropping only the nested leaf would leave an `auth` block the + client refuses at construction, measured). + + ## FROM → TO + + ```yaml + # before — parsed green; password stored cleartext in sys_metadata and + # resolved into MongoCredentials at connect + driver: mongodb + config: + url: mongodb://app@mongo.internal:27017/events + options: + replicaSet: rs0 + auth: { username: app, password: PLAINTEXT-IN-METADATA } + + # after — rejected with the binder prescription; bind the secret instead + driver: mongodb + config: + url: mongodb://app@mongo.internal:27017/events + options: + replicaSet: rs0 + 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 — and auto-dropping only the nested password + would leave an `auth` block the MongoDB client refuses outright. + + + +- 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. + + + +- d491625: feat(spec): refuse the contradictory pair "`external.credentialsRef` bound + a mongo `config.url` naming no user" at publish (#9041) + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`, like the sibling refusals #8337 + and #9040; the migration prescription is registered under protocol major 18, + where `os migrate meta` users will look). + + The "absence must be loud" half of the #8696 family, previously unserved: + after #8696 a mongo datasource that binds `external.credentialsRef` and + authors a `config.url` gets the secret injected as MongoClient `auth` — but + `auth` needs a username as well as a password, and with `url` present the only + place the username can come from is the URL's own userinfo. So the injection + is conditional on the URL naming a user: + + - `mongodb://app@db.internal:27017/app` + bound secret → injected, correct; + - `mongodb://db.internal:27017/app` + bound secret → **nothing happens** — the + datasource connects anonymously and the operator is told nothing. + + The second shape is a configuration that cannot work as written; it is now + refused at the datasource level (`DatasourceSchema`'s refinement — the one + door that sees both halves at once; a config-level refinement cannot, because + `credentialsRef` sits on the datasource and `url` inside `config`). The + refusal names BOTH valid authoring fixes without prescribing either: add the + username to the URL, or drop the binding. + + **Scope fences, each measured**: mongodb arm only, legacy `driver: 'mongo'` + rows judged identically via `resolveDriverId` (the postgres arm injects on a + user-less DSN by its own measured mechanism, #8873, and is not assumed to + share the defect); "names no user" means `urlUserinfoUsername` answers + `undefined` — the present-but-empty userinfo forms already throw in + MongoClient itself (`MongoParseError: URI contained empty userinfo section`); + an empty-string `credentialsRef` is not a binding (mirrors the connect path's + truthy check); the composed branch (no `url`) is untouched — its discrete + `username` field is live. Injecting a fabricated empty username instead of + refusing was measured worse on mongodb@7.5.0: it turns a connection that works + anonymously today into a guaranteed handshake failure. Composes independently + with the sibling refusals (#8082 userinfo, #8336 placeholders, #9040 options + passthrough) — one artefact violating several reports each at its own path. + + ## FROM → TO + + ```yaml + # before — parsed green; the binding was a silent no-op and the datasource + # connected anonymously with the bound secret unused + driver: mongodb + config: + url: mongodb://mongo.internal:27017/events + external: + credentialsRef: sys_secret:01J9ZK4T2N + + # after (authenticated intent) — name the user in the URL; the bound secret + # is injected at connect (#8696) + driver: mongodb + config: + url: mongodb://app@mongo.internal:27017/events + external: + credentialsRef: sys_secret:01J9ZK4T2N + + # after (anonymous intent) — drop the binding that could never land + driver: mongodb + config: + url: mongodb://mongo.internal:27017/events + ``` + + There is deliberately no automatic rewrite: the two fixes are contradictory + intents — authenticate (add the username) versus anonymous (drop the binding) + — and choosing between them requires knowing what the datasource is for. + + + +- 716ac9b: fix(driver-sql): one unresolvable WHERE column, one answer — `find()` and `count()` both refuse with `INVALID_FILTER` / 400 naming the column (#8790) + + **BREAKING** accept-set narrowing on a GA public data API, shipped as `minor` + under the lockstep launch-window convention. The migration prescription is + registered under protocol major 18, where `os migrate meta` users will look. + + + + ## The defect + + One predicate had two answers. `SqlDriver.findRows()` carries the #3821 + unknown-column recovery ladder, and every rung of it is built from + `buildBase()`, which **always re-applies `query.where`**. So the ladder can drop + a projection and can drop an ORDER BY, but it can never drop the clause that + actually failed when the unresolvable column is in the WHERE — both rungs raise + the same error and the method fell to `return []`. `SqlDriver.count()` runs a + separate statement and has no ladder at all, so the identical predicate threw. + + Measured on a real `SqlDriver` over better-sqlite3, one table, one seeded row: + + ``` + where { 'title.x': 'y' } + find() -> 0 rows, NO ERROR + count() -> THREW code=SQLITE_ERROR status=undefined + select count(*) as `count` from `task` where `title`.`x` = 'y' + - no such column: title.x + + CONTROL where { title: 'Design' } + find() -> 1 row + count() -> 1 + ``` + + A list view calls both halves, so one query produced an empty page from the rows + half and a 500-shaped failure from the total half. A caller reading only the rows + got a silent empty page that says "no records exist" for what was really "your + predicate never ran" — the single most AI-legible failure to get wrong, since an + agent reads "no matching records" and writes its next query on that belief. + + The thrown half was no better: the dialect's own `code`, no `status` (so an + unclassified 5xx at the REST boundary rather than a caller mistake), and the + statement's **bound literals inlined in the message** — the same predicate-text + disclosure shape #7929 redacted elsewhere. + + ## The fix + + Ruled 2026-08-15 on #8790: **refuse both halves** with `INVALID_FILTER` / 400, + naming the column. That envelope is not minted here — it is what every sibling + refusal on this path already answers, required on both SQL drivers by + `cross-field-conformance-cases.ts` and pinned by + `sql-driver-boolean-identity.test.ts` and + `sql-driver-cross-field-conformance.test.ts`. What closes is a + declared-vs-enforced gap, not a new posture. + + The caller-visible message names the column and the object and nothing else. The + dialect's own message — the compiled statement, bound literals and all — goes to + the **server log** instead, so the operator keeps the debugging aid that + `count()`'s raw throw used to provide without it reaching the caller. + + **The #3821 ladder keeps both of its recoveries.** Only the WHERE-failure + terminal `return []` became a refusal, and the asymmetry is the ruling rather + than an oversight: "rows matter more than their order" is an argument about how + rows are _presented_, and it does not transfer to a predicate. A dropped sort is + a correct answer in an unhelpful order; a dropped WHERE is records the caller + explicitly excluded. Recover-both was rejected for exactly that reason. + + ## Reach, stated rather than assumed + + The refusal fires on the wordings the ladder has always recognised — SQLite + (`no such column: x`) and Postgres (`column "x" does not exist`). MySQL spells + the condition `Unknown column 'x' in 'where clause'`, which neither arm matches, + so on MySQL an unresolvable column still travels out as the raw dialect error. + That gap is pinned as a fact in the new suite and filed separately: widening the + predicate would also hand MySQL the #3821 projection and ORDER-BY recoveries it + has never had, which is an accept-set change in the opposite direction from this + one. + + ## Who is affected + + Callers that reach the driver with a filter key the table has no column for. The + ingress doors already refuse this where they can judge — `assertFilterFieldsExist` + (`@objectstack/metadata-protocol`) answers `INVALID_FIELD` / 400 for everything + reaching `findData`, with the sentence this refusal now echoes verbatim: _a + filter on a field that does not exist can only match zero records, so the query + was refused instead of answered with an empty list_. What changes is the + backstop underneath them: a registry the door could not read, and a dotted key + judged on its head segment only. + +- a8189ae: feat(objectql,metadata-protocol): refuse a dotted filter key whose head is a relation, a formula, or a plain scalar — at both doors (#8371) + + + + **BREAKING** accept-set narrowing on the FILTER axis, 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 `objectstack migrate +meta` users will look). + + FILTER was the last of the four query axes with no verdict for a dotted name: + SORT refuses it (#4256), PROJECTION refuses it at both doors (#7589), while + `where: { 'project_id.name': 'Apollo' }` cleared the unknown-field check on its + head segment and answered `200` with zero rows. Measured across all three + drivers before ruling (#8371): relation-head, formula-head, system-column-head + and plain-scalar-head dotted filters return zero rows on `driver-memory`, + `driver-sql` and `driver-mongodb` alike — a lookup stores the related record's + scalar id, so there is no working capability for this refusal to remove; every + answer was a silent empty list indistinguishable from an empty table, and the + virtual case answered one unserviceable intent two ways by spelling + (`{is_open: true}` refused since #8296, `{'is_open.x': true}` not). + + **What is refused:** a dotted filter key whose head field is a relation + (`lookup`/`master_detail`/`user`/`tree`), a virtual `formula`, or a plain + scalar — `400 INVALID_FIELD`, naming the whole offending key, at both the REST + ingress (`assertFilterFieldsExist`) and the engine's own filter seam + (`assertFilterIsMaterializable`, reached by saved reports, flows and dashboard + widgets whose filters never pass the ingress). Both doors judge the head by the + shared `@objectstack/spec/data` classification (`classifyDottedFilterHead`, + new export), so they cannot drift apart. Precedence mirrors the sort axis: + `unknown` > `dotted` > unmaterializable. + + **What stays accepted:** a dotted path into a structured/JSON head + (`{'address.city': 'Beijing'}`) — deliberately unjudged per the ruling, since + it genuinely works on two of three backends; array-valued and file heads, for + the same reason; the nested-relation OBJECT form `{ owner: { region: 'NA' } }`; + and every undotted spelling, byte-identically. + + ## FROM → TO + + ```ts + // before — 200, zero rows, indistinguishable from an empty table + await engine.find("task", { where: { "project_id.name": "Apollo" } }); + + // after — 400 INVALID_FIELD naming 'project_id.name', with the remedy: + // denormalise the value onto a stored field of the queried object and + // filter that (or, to test the relation itself, filter the head field): + await engine.find("task", { where: { project_id: apolloId } }); + ``` + + There is deliberately no automatic rewrite: the platform cannot invent the + stored column the remedy prescribes, and it must not join or post-filter + instead — the drivers have already applied `limit`/`offset`, so a post-hoc + predicate would filter an arbitrary page. + +- 8b9eba5: feat(spec): field-level `relatedListFilter` — a declarative default filter for auto-derived related lists (#8704) + + + + The field-level related-list family (`relatedList` / `relatedListTitle` / + `relatedListColumns`) gains its fourth member, `relatedListFilter` — closing the + gap where the only way to filter an auto-derived related list was to abandon the + auto-derived record page for a hand-written `record:related_list` page + (maintainer ruling 2026-08-15 on #8704). + + - **No new filter dialect**: the key carries the canonical Query-DSL + `FilterCondition` (the same authoring face as a query `where`, dataset scope + filters, and `summaryOperations.filter`). The FILTER-axis doors therefore + apply automatically — the schema door refuses bare date-range preset + comparands in ordering positions at parse (#8793), and the engine doors judge + the composed query at run time (`formula` keys refused `INVALID_FIELD`, + #8296). + - **Contract semantics, pinned**: the declared constraint is AND-composed with + the parent-relationship condition `{ [referenceField]: parentId }` — an + authored constraint, never a user-editable suggestion — and the related-list + tab badge count honors the same composed filter, so counts match visible + rows. Both clauses are normative in the key's contract text and pinned by + tests. + - **`@objectstack/lint`**: the shared authored-filter walk (`FILTER_KEYS`) now + recognizes `relatedListFilter`, extending the filter-token, empty-combinator + and preset-comparand rules to the new position. + + The consumption half (RecordDetailView auto-derivation + tab badge) is + objectui#4664, `Blocked-by:` this change; until it lands the key is ledgered + `planned` with an author warning. + +- 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. + + + +- c5ac5e4: feat(spec): `placeholder` becomes a declared `FieldSchema` key — the producer moves to meet four shipped objectui render surfaces (#9019, maintainer Option C ruling on objectui#4676) + + + + `FieldSchema` refused `placeholder` by name ("never a FieldSchema key. Author + hint text through `inlineHelpText` or `description`.") while four objectui + packages plus `apps/console` — plugin-form's auto-generated and sectioned + forms, plugin-detail's inline edit, app-shell's field-backed action params + (whose module header documents the inheritance as intended), and console's + FormPage — apply an object-field-level `placeholder` at render time, feeding + the `@object-ui/fields` widgets. That was the preview-renders/save-422s trap: + the designer preview rendered the key, `PUT /api/v1/meta/object/:name` + refused it. + + Per the 2026-08-16 maintainer ruling (Option C on objectui#4676, measured in + its report comment 5301288148): + + - `placeholder` is now a declared optional string key on `FieldSchema`, with + the semantics the renderers already implement: in-input placeholder text + (the HTML `placeholder` attribute), distinct from `inlineHelpText` + (always-visible help beside/under the input) and `description` (tooltip). + - The `FIELD_KEY_GUIDANCE` retirement entry steering authors away from the key + is removed — after this change that prose would contradict the contract. + - The Studio metadata forms (`object.form.ts` quick-add grid, `field.form.ts` + full editor) offer the key, and the liveness ledger carries a `live` verdict + with the measured cross-repo evidence. + + The matching translation surface (`FieldTranslation.placeholder`) was already + declared, so a translated placeholder now has a declared base key to land on. + +- a777944: feat(spec,lint): refuse a bare date-range preset name in an ordering filter comparand at publish time (#8793 — the ruled C half of #8690) + + **BREAKING** accept-set narrowing on a published authoring surface, 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). + + `last_7_days` / `last_30_days` / `last_90_days` and their ten calendar + siblings are real, declared preset names — for the dashboard date-filter + positions, where the console lowers them to `{date-macro}` bounds before any + query is sent. Authored as a bare filter comparand nothing resolves them: + measured on #8690, `$gte "last_30_days"` returned HTTP 200 with 0 of 51 rows + where `$gte "{30_days_ago}"` returned the 38 in-window. The engine now + refuses the bare name on a declared temporal field at query time + (`INVALID_FILTER` / 400, PR #8808 — the B half); this change is the + authoring-time half the same ruling shipped alongside it. + + **What is refused — ordering positions only, in all three authored filter + shapes:** a `$gt` / `$gte` / `$lt` / `$lte` comparand or `$between` endpoint + on every carrier of `FilterConditionSchema` (dashboard widget filter, dataset + filter, report `runtimeFilter`, page/component filter, rollup filter), a + `greater_than` / `less_than` / `before` / `after` / `between` view filter + rule value, and an ordering `[field, op, value]` filter triple (the latter + two via `@objectstack/lint`'s new gating rule `filter-preset-comparand`, + which also runs at the runtime publish gate for `dashboard` / `view` / + `object` / `page` / `flow` writes). The refusal names the offending value, + the position, and the exact `{date-macro}` window that works. + + **What stays accepted:** the preset names in the dashboard date-filter + positions (`dateRange.defaultRange`, a date global filter's `defaultValue`) — + the only positions any layer ever resolved them; equality and membership + comparands (`{ period: 'this_quarter' }`, `$in: [...]`) — a select/picklist + column legitimately stores colliding values, and the engine's field-typed + door already covers the temporal case; undeclared strings + (`'not-a-date-at-all'`) — the field-typed engine door owns those; and the + empty-string cell, which stays its own card by ruling. + + ## FROM → TO + + ```ts + // before — parsed green, returned a silent zero (or 400 at query time since #8808) + filter: { + closed_at: { + $gte: "last_30_days"; + } + } + + // after — rejected naming the window; write the date-macro spelling + filter: { + closed_at: { + $gte: "{30_days_ago}"; + } + } + // calendar presets prescribe their pair: + filter: { + closed_at: { + $between: ["{week_start}", "{week_end}"]; + } + } + ``` + + `DATE_RANGE_PRESETS` moved to `@objectstack/spec/data` + (`data/date-range-presets.ts`) with `ui` re-exporting it, so both import + paths keep working; `DATE_RANGE_PRESET_MACRO_WINDOWS` (the per-preset macro + window table the refusals quote) and `isDateRangePresetName` are new exports. + + + +- 65589d6: feat(spec): `icontains` joins the view and infix filter vocabularies, closing the dialect gap on the capability every driver executes (#8934) + + `$icontains` has been executable on every driver and evaluation face since + #5702/#6520, yet it was authorable from exactly one of the three filter + dialects — the MongoDB-style `FieldOperatorsSchema`. Maintainer ruling + (Option A on #8934): the two remaining vocabularies gain the canonical + spelling. + + - `VIEW_FILTER_OPERATORS` (`ui/view.zod.ts`) gains `icontains`, so a + `ViewFilterRule` can declare a case-insensitive contains. No alias rows: + the alias table bridges spellings already living in stored metadata, and a + new canonical operator has none. + - `AST_OPERATOR_MAP` (`data/filter.zod.ts`) gains `icontains` → `$icontains`, + so `isFilterAST` accepts the infix spelling and `parseFilterAST` lowers it + to the operator the drivers already run. `canonicalAstOperator` round-trips + it through the generic path (`CANONICAL_INFIX` row added). + - Boundary preserved, per the ruling: `icontains`/`$icontains` (LIKE-escaped + substring — a comparand `%` is a LITERAL) and `ilike`/`$ilike` (raw LIKE + pattern) are NOT aliases of each other in either vocabulary, and there is no + `not_icontains` — the `$` dialect has no `$notIcontains`, and the authoring + vocabularies mirror the executed set rather than widening it. + - The parity suite (`filter-view-operator-parity.test.ts`) and + `FILTER_TEXT_CASES` extend accordingly, including a conformance case that + lowers the infix spelling and pins `%`-literalness on every backend that + runs the table. The comparand-type door already judged `$icontains` + (a `FieldOperatorsSchema` key since #5701) — no change needed there. + +- 2c86fe3: feat(spec): retire `ApiKeySchema` — the identity module no longer publishes a second, fictional declaration of `sys_api_key` (#8715, ADR-0049) + + + + **BREAKING** public-surface removal, 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 #8586 precedent). + + `ApiKeySchema` (and its `ApiKey` / `ApiKeyParsed` types) documented + better-auth's `apiKey` **plugin** schema — a plugin this platform does not + load: `start` and `lastRefetchAt` name columns that do not exist; `enabled` + inverts the real `revoked` column's polarity; `rateLimitEnabled` / + `rateLimitTimeWindow` / `rateLimitMax` / `remaining` advertise a per-key + rate-limit capability nothing implements; `permissions` and `metadata` have no + columns; `organizationId` is camelCase fiction next to the real snake_case + `active_organization_id`. Zero consumers anywhere in the monorepo outside its + own unit test — one table had two declarations, and the published one was + fiction (maintainer-ruled DELETE, 2026-08-15). + + **What breaks:** `import { ApiKeySchema, ApiKey, ApiKeyParsed }` from + `@objectstack/spec` or `@objectstack/spec/identity` is TS2305 after upgrade. + The generated reference page's `ApiKey` section and the 19 + `identity/ApiKey:*` authorable-surface keys disappear with the schema. + + **What stays:** everything real. The single declaration of `sys_api_key` is + the ObjectSchema in `@objectstack/platform-objects` + (`identity/sys-api-key.object.ts`) — columns `name, prefix, user_id, +active_organization_id, scopes, expires_at, last_used_at, revoked, key, id, +created_at, updated_at`; rows are minted by `POST /api/v1/keys` and verified + by `core/src/security/api-key.ts`, keyed by the `osk_` prefix. Neither ever + read the deleted schema, so runtime behaviour is byte-identical. + `UserSchema` / `AccountSchema` / `VerificationTokenSchema` and the + organization module survive unchanged. + + The retirement kit: + + - schema deleted in place, with the in-module explanatory block naming the + live declaration (`packages/spec/src/identity/identity.zod.ts`) + - ADR-0087 registration: retired-def entry `identity/ApiKey` + D3 semantic + entry `identity-api-key-schema-retired`, both under protocol 18 (route 3 — + no carrier key and no authored document, so no tombstone and no D2 + conversion; the registry entries ARE the declaration) + - pin tests: `identity/api-key-retirement.test.ts` (zero holders on every + public entry, survivors stand) and platform-objects' + `sys-api-key-single-declaration.test.ts` (the real column set, spec's + runtime namespace lost the name) + - generated baselines regenerated: authorable surface (−19 keys), JSON-schema + manifest (−1 def), api-surface / export-origins (−3 names), reference docs + - `cloud/developer-portal.zod.ts` prose corrected: marketplace API keys point + at the `sys_api_key` object and `POST /api/v1/keys`, not at + `Identity.ApiKeySchema` (the marketplace-key plan is ruled not live) + + ## FROM → TO + + ```ts + // before — type-checked green against a schema no runtime ever read + import { ApiKeySchema, type ApiKey } from "@objectstack/spec/identity"; + const key: ApiKey = { + id, + name, + userId, + enabled: true, + rateLimitMax: 100 /* … */, + }; + + // after — read the real table: the sys_api_key ObjectSchema in + // @objectstack/platform-objects (snake_case, `revoked` not `enabled`); + // mint via POST /api/v1/keys, verify via core/src/security/api-key.ts. + import { SysApiKey } from "@objectstack/platform-objects"; + ``` + +- 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. + ``` + + + +- c308a4f: feat(spec): refuse undeclared keys on object `indexes[]` entries (#4001 批 20 site 14, the held `IndexSchema`) + + **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). + + `IndexSchema` — 批 20's one deliberately-held site — is now `strictObject` like + its thirteen siblings. The hold was a measured #5114-class risk, not an + unfinished to-do: objectui's embedded index editor shipped a drifted + hand-copied schema (`FALLBACK_SCHEMAS.index`) offering `where` for a + partial-index predicate and `brin` in an algorithm enum, spliced its form + output into `object.indexes[]` and PUT the whole object — so closing the shape + would have 422'd a control the console itself rendered. objectui#4772 + converged that editor to the declared surface (`name` / `fields` / `unique`), + spending the hold's evidence. + + Before this change an undeclared key on an index parsed clean and was silently + dropped: an admin filling the old "Partial-index predicate" control got a + green save while no driver ever read the predicate + (`SqlDriver.syncDeclaredIndexes` consumes `name`/`fields`/`unique` only). + + **What is refused:** any key the shape does not declare, with a prescriptive + message naming the surface and the offending key. `where` carries a curated + guidance entry — the predicate belongs at the database layer + (`CREATE [UNIQUE] INDEX … WHERE` from a runtime migration, the + `ensureOverlayIndex` pattern), deliberately NOT a rename onto the retired + `partial` tombstone (a suggestion pointing into a second rejection). + + **What stays accepted:** every declared key byte-identically, including every + ADR-0120 `unique` scope spelling — and the protocol-17 `type`/`partial` + tombstones keep answering their own migration prescription rather than + degrading to a generic `unrecognized_keys`. + + ## FROM → TO + + ```ts + // before — parsed green; the predicate was silently dropped, the index built FULL + indexes: [{ fields: ["status"], where: "status = 'open'" }]; + + // after — rejected with the database-layer prescription; declare only what is materialized + indexes: [{ fields: ["status"] }]; + // …and issue `CREATE INDEX … WHERE ` from a runtime migration when + // a partial index is actually needed. + ``` + + There is deliberately no automatic rewrite: an undeclared key here either + names a capability the declaration surface does not deliver (blessing it would + be declared-but-unenforced surface, ADR-0078) or is a spelling of a declared + one, which the rejection names. `os migrate meta` surfaces the change as a + structured TODO (semantic entry `object-index-unknown-keys-refused`, protocol + major 18 — this refusal is not part of the v17.0.0 cut). + + + +- 3851f87: Partial field masking (#8993): `FieldSchema` declares `maskingRule` — a closed + preset enum (`phone`, `id_card`, `bank_account`, `email`, `name`) plus a + `{ keepHead, keepTail }` escape hatch — and plugin-security's `FieldMasker` + enforces it in the same PR (ADR-0049 declare = enforce; the key re-enters the + schema only with its runtime consumer attached, honouring the 2026-06 prune in + spirit). + + A field declaring a rule is served masked-but-recognisable (`138****5678`) to + every non-system caller; the field's `requiredPermissions` (ADR-0066 D3) is the + unmask gate — holders of all listed capabilities read the full value. A + permission set that marks the field non-readable still deletes it entirely. + Masking rides the single runtime channel, so API callers, browser users, the + CSV/XLSX export route and the AI-context interceptor all see the same + deterministic, length-preserving masked value. Masked callers cannot filter, + sort, group or aggregate on the field (403, the FLS predicate-oracle guard), + and a write that round-trips a masked placeholder is refused with + `400 VALIDATION_ERROR` instead of silently overwriting the stored value. + New exports: `FieldMaskingRuleSchema`, `FieldMaskingKeepSchema`, + `FIELD_MASKING_PRESETS`, `maskFieldValue`, `MASK_CHAR`. + +- 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). + + + +- d634e66: feat(spec): export `urlUserinfoUsername` — the username half of the shared URL userinfo grammar (#8876) + + `@objectstack/spec/data` owns the DSN userinfo grammar (`urlUserinfoPassword` / + `redactUrlPassword`, #8082/#8300) but exported only its password half. The + mongo DSN arm (#8696) must inject a bound `external.credentialsRef` secret via + `MongoClient`'s `auth` option, which requires the username the URL already + names — and reading it needs this grammar, because `new URL()` throws + `ERR_INVALID_URL` on the multi-host DSN form `MongoConfigSchema` documents + (`mongodb://app@h1:27017,h2:27017/app`, measured). A local copy in + `service-datasource` is the shape the #8082 single-parse ruling refuses by + name. + + **Additive only.** The new accessor shares the password half's boundary parse + by construction (both now call one internal RFC-3986 userinfo parse), returns + the RAW component (percent-encoding preserved, decoding stays with the + caller), answers `''` for an empty username inside present userinfo and + `undefined` when the string carries no userinfo at all, and still parses the + publish-refused `user:password@` shape correctly — stored legacy rows carry + it, and #8155's migration path must judge exactly those rows. No Zod schema + changes: every input that validated before validates identically after; the + read-path redaction alignment pin now covers the username half too (redaction + preserves the username byte-for-byte). + +### Patch Changes + +- abcf853: docs(spec): `FieldSchema` points a bare `currency` key at the declarable `currencyConfig` form (#8163) + + `currency` has never been a declared `FieldSchema` key — only `currencyConfig` + is. Writing the natural spelling was always a loud parse error, but a **bare** + one: the rejection carried only the surface history line ("Until #4001 closed + this shape these were dropped silently…"), with no pointer to the declarable + form. The spelling is not hypothetical — objectui's `resolveFieldCurrency` + reads `field.currency` first from looser grid/column configs, so it circulates + in configs an AI author will have seen. + + The target is a NESTED key (`currencyConfig.defaultCurrency` under + `currencyMode: 'fixed'`), which a flat `aliases` rename cannot express — so + this is prose (`guidance`), the same `storageNotNull`-style case already on + this surface: `currency` is not a field key; a fixed currency is declared as + `currencyConfig: { currencyMode: 'fixed', defaultCurrency: '…' }`. A field + without one uses the tenant default at runtime. + + Accept/reject is byte-for-byte unchanged — `currency` was rejected before this + change and stays rejected after it; only the rejection's message gains a + prescription. + +- 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. + +- d00d2f6: fix(driver-sql): refuse — and roll back — a MySQL upsert that merges onto a row the caller never identified (#8807) + + `ON DUPLICATE KEY UPDATE` carries no conflict target, so on MySQL a merge lands on + whichever UNIQUE key the row collides with first. `#8621` closed the half where + nothing backed a caller-named target; `#8755` closed the half where a rival key + could absorb a caller-named one. This closes the residue those two left by + construction: the `conflictKeys`-less call and the `['id']` call, which compile + byte-identically and which no pre-flight can judge, because neither names anything. + + Measured on live MySQL 8.0.46, `email` and `tax_id` both `unique: true`, **no** + `conflictKeys`: seeding `{email:'d@b.com', tax_id:'T-9'}` inserted one row, and + `{email:'e@b.com', tax_id:'T-9'}` then resolved with no error — one row, the + _seeded_ one, its `email` rewritten `d@b.com` to `e@b.com`, and the id the caller + was handed back present in no row at all. The identical pair on SQLite raises + `UNIQUE constraint failed: …tax_id` and leaves the seeded row untouched. + + Per the maintainer ruling on #8807 this enforces a contract principle, not a MySQL + detail: _an `upsert` must never modify a row whose identity the caller did not + supply and whose conflict key it did not name._ + + **Accept-set change, MySQL only.** After the statement and inside the same + transaction, the driver checks whether the row it landed on is the one the call + supplied. If it is not, the write is **rolled back** and the call refuses with + `code: 'VALIDATION_ERROR'`, `status: 400`, naming the UNIQUE key that absorbed the + merge and stating that nothing was changed. + + The check is exact rather than heuristic — `id` is insert-only on the merge path + (#8622), so a row merged on the primary key always still carries the supplied id + and a row merged on any other key never does — which is why it has no false + refusals. + + Deliberately unchanged: tables whose only key is the primary key are not verified + and open no transaction, so the ordinary upsert keeps its single round trip; every + insert and every re-upsert of the same row still merges; the caller-named + single-unique-key fast path is untouched; and SQLite and PostgreSQL are unaffected, + because `ON CONFLICT (...)` already honours the named arbiter. The lifecycle + archiver's hot→cold copy passes by construction — it supplies each row's own id — + and of the two objects declaring `lifecycle.archive`, neither carries a + non-primary unique field. The dialect limit is documented under + _Database Drivers → MySQL_. + +- 1a7f907: fix(metadata): a package publish refuses a draft stored under a non-canonical metadata type, and the ADR-0010 audit writer asserts its `type` instead of folding it (#8908) + + + + **Two tightenings, one card, because they are the same defect at two layers.** + + `publishPackageDrafts` reads `sys_metadata` rows **at rest**, so #7894's `/meta` + boundary fold never reached it. `promoteDraftForPublish` folds the stored + spelling through `PLURAL_TO_SINGULAR` — the _manifest-collection_ map, which + legitimately omits types that are not stack collections. For those the fold is a + **no-op**: the lookup key equals the stored spelling, the draft resolves, and the + publish mints an ACTIVE row in the namespace `PUT /meta/field/…` answers + 403 NOT_OVERRIDABLE for. Measured on the card with the real repository over a + stub engine: + + ``` + publishPackageDrafts({ packageId: 'app.demo' }) + → { success: true, publishedCount: 1, published: [{ type: 'fields', name: 'legacy_field' }] } + active row: { type: 'fields', name: 'legacy_field', package_id: 'app.demo' } + audit row: { type: 'fields', name: 'legacy_field', outcome: 'allowed', code: 'ok' } + ``` + + Every registry read and every compliance query on `field` misses an item the + platform just reported as published — the #4432 shadowing shape, minted at + publish time instead of at the URL, and the last route by which a pre-#7894 row + could be re-promoted rather than migrated. + + **1. The publish refuses it, at the pre-flight, batch-atomically.** Same shape as + the ADR-0028 namespace-prefix gate that already stands there: found before + anything is promoted, failing the whole batch (`publishedCount: 0`, + `published: []`) rather than publishing the healthy siblings around it, with one + audit row per violation. The refusal names the row, names the canonical type, and + states the re-author path; `failed[].code` is the new + `STORED_TYPE_NOT_CANONICAL`, and the audit column's spelling is + `stored_type_not_canonical`. + + The rule is **derived, not a list**: a spelling the platform's URL/registry map + folds elsewhere _and_ the manifest map leaves unchanged. Against the real maps + that is **six** spellings — `fields`, `seeds`, `external_catalogs`, + `externalCatalogs`, `translations`, `email_templates` — where the card named + four; the last two would have been missing from any hand-written list, and a + newly declared type that never reaches the manifest map is covered on the day it + is declared. A manifest-**present** plural (`objects`) is deliberately _not_ in + the class: it is already fail-closed at the promote (`NO_DRAFT`, batch aborted) + and keeps that verdict. + + ⛔ Deliberately **not** included: migrating the row (a `_migrate-stored` / + boot-reconciliation conversion). That was the other option on the card and is + explicitly unruled — it stays available as a follow-up with its own appetite. + + **2. `recordMetadataAudit` refuses a non-canonical `type` (`AUDIT_TYPE_NOT_CANONICAL`) + instead of folding it.** The writer used to open with + `type: PLURAL_TO_SINGULAR[entry.type] ?? entry.type` — a lenient consumer, and a + **tolerant-and-incomplete** one: the fold read the same manifest map, so the + compliance trail came out canonical for the 29 types that never needed it and + non-canonical for exactly the ones that did. Ruled the same direction as the + refusal above: **fold at the boundary, assert at the writer.** Every call site + that builds a row out of an at-rest `type` — all of them on + `publishPackageDrafts` — now folds with `canonicalMetaType`; the `/meta` routes + were already canonical by the time they got there. The throw sits **outside** the + writer's best-effort `try`, because inside it the method's own `catch` would + degrade the assert into a `console.warn`. + + The assert cannot refuse a canonical type (no canonical spelling folds + elsewhere — 33 of 33, measured) nor a plugin-registered or otherwise + unrecognised kind (`canonicalMetaType` is the identity for anything the static + map does not carry), so it narrows the accept set without closing it. + + **Reachability was enumerated before the assert landed**, as the ruling required: + `recordMetadataAudit` is private to `protocol.ts` with 11 call sites, `sys_metadata` + rows have exactly one producer in the repository (`saveMetaItem` → `repo.put`, + post-fold), and no current write path can mint a non-canonical stored type. The + only non-canonical types that ever reached an audit write came from the batch + publish's at-rest rows, which is what the boundary folds now cover. + + Also fixed, as a consequence of that fold rather than as a separate change: on + the batch route `getEffectiveLock`'s overlay limb was queried with the raw stored + spelling, so an ADR-0010 `_lock` carried by the canonical active row was looked + up under a `type` no row has and came back `'none'` — the verdict "the author + declared no protection". That is the batch twin of the hole #8769 closed on + `publishMetaItem`. + +- c80e7ae: fix(spec): reference tables stop marking `.default()`-bearing members as required, and name the default instead (#8703) + + The Required column of every `content/docs/references/**` property table mirrored + the emitted JSON Schema's `required` array. `build-schemas.ts` emits the + **output** (post-parse) shape for 1458 of the 1582 published documents, falling + back to the **input** shape only when output emission throws — and in an output + shape a `.default()`-bearing member is listed in `required`, because the parse + always produces it. So the column answered "must I write this?" with `✅` for + keys the author may freely omit. + + **Measured on the emitted tree: 2526 property occurrences across 529 documents** + were in `required` while carrying a `default`. `kernel/metadata-plugin.mdx` is + the specimen the card was filed on — `enableEvents`, `validateOnWrite`, + `enableVersioning`, `cacheMaxItems` and `bootstrap` all read `✅`, and all five + are omittable. + + Two consequences, both fixed here: + + - Reference tables are read far more often by an AI author than by a human + (ADR-0033), and omitting optional keys is that author's normal mode. A wall of + `✅` teaches over-specification, and buries the genuinely-required keys among + the ones that are not. + - The same member rendered `✅` on an output-shape page and `optional` on one of + the 124 input-shape pages, so a refactor that merely flipped a def between the + two emission modes rewrote its whole Required column with no semantic change to + what an author writes. + + **The fix reads `default` rather than `required`**: a property carrying a + `default` is author-omittable by construction in _both_ emission modes, so it now + renders `optional (default: \`false\`)`— strictly more information than either +previous cell, since the value an author gets by omitting the key was nowhere on +the page before. A structural default too wide for the cell renders`optional (has default)`(13 cells; the budget's discontinuity is documented at`INLINE_DEFAULT_WIDTH_LIMIT`), and a property with no default is untouched in + both directions. + + **The JSON Schemas are deliberately unchanged.** `build-schemas.ts` is not + touched by this fix: the emitted artifacts keep describing the post-parse shape + and keep validating post-parse data. Only the doc renderer reads the author's + question differently. 146 reference pages are regenerated. + +- 8bee54b: docs(spec): the `driver-sql-unresolvable-where-column-refused` ledger entry states MySQL's reach as it is after #8926, not as it was at registration (#9060) + + Text amendment to an already-registered ADR-0087 entry — the entry id, `surface` + and `replacement` prescription are unchanged, and no accept/reject behaviour + moves. What changes is the `reason`, which is upgrader-facing documentation: it + is the data source for `objectstack migrate meta`, `spec-changes.json` and the + generated upgrade guide. + + The entry's "Reach, stated rather than assumed" paragraph said MySQL was outside + the refusal — true when #8790 registered it, false the moment #8926 merged (PR + #9061). A MySQL user reading "on MySQL this condition still travels out as the + raw dialect error" would have concluded the migration did not apply to them, + which is exactly wrong after parity. + + The historical paragraph is kept verbatim as the state at registration, and a + dated addendum states both halves of what the one shared predicate did on MySQL: + + - **The envelope** — an unresolvable WHERE column refuses with the same + `INVALID_FILTER` / 400 naming the column, instead of the raw + `ER_BAD_FIELD_ERROR` with the statement's bound literals inlined. + - **The recoveries** — MySQL also gained the #3821 projection and ORDER-BY + recoveries it never had, so those positions now return recovered rows where + they used to throw. + + Both arrive together because `ER_BAD_FIELD_ERROR` spells every clause position + with one sentence, so all three ride one arm of the predicate — pinned as the + ruled direction by the widened sweep in + `sql-driver-unresolvable-where-column-refusal.test.ts`. Unchanged by that + ruling, and said so in the addendum: a dotted filter key is still classified per + dialect, the axis #8371 owns. + +- 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 +2293,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 +5267,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 +5812,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 +8510,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 +9546,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 +11916,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 +23581,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 +24101,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 +35903,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 +36561,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..04058267a8 100644 --- a/packages/triggers/trigger-api/CHANGELOG.md +++ b/packages/triggers/trigger-api/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/trigger-api +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..271122b30c 100644 --- a/packages/triggers/trigger-record-change/CHANGELOG.md +++ b/packages/triggers/trigger-record-change/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/plugin-trigger-record-change +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..89d1c0a21b 100644 --- a/packages/triggers/trigger-schedule/CHANGELOG.md +++ b/packages/triggers/trigger-schedule/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/plugin-trigger-schedule +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..327dd8eb49 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,189 @@ # @objectstack/types +## 17.1.0 + +### Patch Changes + +- 27a567d: fix(types): teach the internal-leak predicate MySQL's three error templates (#8739) + + `looksLikeInternalErrorLeak` decides whether a message is a driver dump that + must not reach an API client. It is applied at three HTTP boundaries + (`@objectstack/rest`'s `mapDataError`, `@objectstack/runtime`'s + dispatcher-plugin and endpoint-executor, the hono adapter) and by + `@objectstack/objectql`'s log redactor. Its dialect list covered the SQLite + family and Postgres; on a MySQL deployment it returned `false` for every one of + these conditions — **silent, not clearing**. + + Under the maintainer's 2026-08-15 ruling on #8739, **MySQL is a supported + deployment target**, not merely a tested dialect — the answer already implied by + what is published (`OS_DATABASE_DRIVER=mysql` as a documented deployment knob, + `MysqlConfig` as authorable datasource config, per-field MySQL DDL in + `types.mdx`) and by a required CI check that stands up a live `mysql:8.0`. A + supported target's driver text reaches those boundaries in production, so its + templates belong in the list. + + **Now recognised** — one per condition the other two dialects were already + covered for, each anchored on MySQL's own errmsg template rather than on a bare + substring: + + - `Table 'app.t' doesn't exist` (ER_NO_SUCH_TABLE 1146). MySQL's contracted + spelling quotes `db.table` as one identifier, so the Postgres + `relation "t" does not exist` limb could never reach it. + - `Unknown column 'c' in 'field list'` (ER_BAD_FIELD_ERROR 1054). Both quoted + parts are required; the second is MySQL's clause name (`field list`, + `where clause`, `order clause`, `on clause`), and it is what distinguishes the + driver's template from a sentence that merely calls a column unknown. + - `Duplicate entry 'x' for key 'i'` (ER_DUP_ENTRY 1062). The `for key` tail plus + a quoted index is the anchor. This is the one MySQL template whose text embeds + a **caller's value** rather than an identifier — SQLite's + `UNIQUE constraint failed: t.c` and Postgres' `violates unique constraint "…"` + both name only an index — which is why closing this gap was worth a behaviour + change rather than another comment. + + **Deliberately still NOT recognised**, so the boundary of the change is on the + record rather than inferred: + + - **MySQL's ACL family** — `Access denied for user 'u'@'h' to database 'd'` + (1044), `SELECT command denied to user … for table 't'` (1142) — the + counterpart of the Postgres `permission denied for table` limb. Nothing in + this repo has raised one off a live server, and the standing rule in this + neighbourhood (`unique-violation.ts`) is that a dialect's spelling is added + once it has been MEASURED off a thrown error, never from a reading of the + manual. `Access denied` also collides with this platform's own security prose + (`[Security] Access denied: …`), so a guessed pattern here would over-match — + and over-matching suppresses diagnostics an operator needs. + - **MSSQL and Oracle** — `Invalid object name 'sys_metadata'.`, + `ORA-00942: table or view does not exist` still return `false`. + - **Prose that shares the keywords without the driver's anchoring** — an import + summary saying `duplicate entry in the uploaded file`, a mapping message + saying `Unknown column in the uploaded CSV header`, `The table you selected +does not exist`. Pinned as negative cases, because a phrasing list that says + "leak" too often replaces real answers with `Internal server error`. + + **The `false`-means-UNCOVERED rule survives the change and keeps a live + subject.** A `false` here has never meant the text is safe, only that the + predicate never learned that dialect — the reading a reviewer on PR #8737 got + wrong while sizing a disclosure residual, which is what produced this card. The + four `toBe(false)` pins PR #8824 planted as a tripwire for this exact moment + went red as designed and are rewritten, not deleted: the same three measured + messages now assert `true`, so a future change that silently drops MySQL + coverage fails there, and a second block keeps the original `false`-means- + uncovered shape pointed at MSSQL and Oracle. `declaresServerFault` remains the + phrasing-independent answer. + + **No status mapping moves.** `@objectstack/rest` answers the 409 conflict + question with `isUniqueViolationError`, above and independently of this + predicate (#6250), so a MySQL duplicate-entry error is still `409 +UNIQUE_VIOLATION` and a MySQL unknown-column error is still `400 INVALID_FIELD` + — both decided before the leak branch is reached. The log redactor is unchanged + too: a bare MySQL diagnostic carries no knex `-` separator, so there is no + statement to cut. Measured across the predicate's full consumer set — types, + objectql, rest, runtime, metadata-protocol, hono, service-package, + service-analytics — the only verdicts that moved are the two that measure this + predicate directly. + + No live MySQL deployment leaking through these boundaries was measured; this + closes a gap in what the boundary recognises, and the card is explicit that no + leak was demonstrated. + +- 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 [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [d491625] +- Updated dependencies [716ac9b] +- Updated dependencies [a8189ae] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [3851f87] +- Updated dependencies [1a7f907] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [8bee54b] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [d634e66] + - @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..c85fa12b32 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -1,5 +1,114 @@ # @objectstack/verify +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [d6e80b2] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e7bccaa] +- Updated dependencies [e43d63a] +- Updated dependencies [5047cb8] +- Updated dependencies [2277443] +- Updated dependencies [a751f7d] +- Updated dependencies [cf0d902] +- Updated dependencies [498f4e8] +- Updated dependencies [cc5c07b] +- Updated dependencies [8640fb2] +- Updated dependencies [2420641] +- Updated dependencies [f57fb38] +- Updated dependencies [3508678] +- Updated dependencies [d491625] +- Updated dependencies [04d03c3] +- Updated dependencies [8656d67] +- Updated dependencies [3d61924] +- Updated dependencies [716ac9b] +- Updated dependencies [6feac91] +- Updated dependencies [5f5e234] +- Updated dependencies [a8189ae] +- Updated dependencies [27a567d] +- Updated dependencies [4ea921c] +- Updated dependencies [3ab2488] +- Updated dependencies [abcf853] +- Updated dependencies [8b9eba5] +- Updated dependencies [d575779] +- Updated dependencies [c5ac5e4] +- Updated dependencies [a777944] +- Updated dependencies [856527c] +- Updated dependencies [65589d6] +- Updated dependencies [2c86fe3] +- Updated dependencies [24173e9] +- Updated dependencies [b705a6c] +- Updated dependencies [f8eb736] +- Updated dependencies [4e71ae1] +- Updated dependencies [20067c5] +- Updated dependencies [d09d0fd] +- Updated dependencies [e783e16] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b537855] +- Updated dependencies [b69d0f5] +- Updated dependencies [4dc8a61] +- Updated dependencies [4d47afe] +- Updated dependencies [4fc4a3c] +- Updated dependencies [90a12fb] +- Updated dependencies [72050cc] +- Updated dependencies [d70428a] +- Updated dependencies [d00d2f6] +- Updated dependencies [c308a4f] +- Updated dependencies [e6e1de4] +- Updated dependencies [3851f87] +- Updated dependencies [c73eacd] +- Updated dependencies [f8537df] +- Updated dependencies [712e185] +- Updated dependencies [693c788] +- Updated dependencies [0961065] +- Updated dependencies [1a7f907] +- Updated dependencies [501ed0e] +- Updated dependencies [f047810] +- Updated dependencies [30d3752] +- Updated dependencies [c80e7ae] +- Updated dependencies [7fc01db] +- Updated dependencies [19db5fa] +- Updated dependencies [2b9d33a] +- Updated dependencies [b53d38e] +- Updated dependencies [8bee54b] +- Updated dependencies [04f8fdb] +- Updated dependencies [c25b2d5] +- 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 [d634e66] + - @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/objectql@17.1.0 + - @objectstack/service-datasource@17.1.0 + - @objectstack/plugin-sharing@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/service-analytics@17.1.0 + - @objectstack/service-settings@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",