diff --git a/.changeset/accordion-item-icon-liveness.md b/.changeset/accordion-item-icon-liveness.md deleted file mode 100644 index f08b335c49..0000000000 --- a/.changeset/accordion-item-icon-liveness.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): record the live read point of `page:accordion` `items[].icon` — a `.describe()` plus an accept-pin, so a liveness sweep stops re-deriving a false retirement candidate (#9881) - -`PageAccordionProps.items[].icon` parsed, rendered, and said nothing about -itself. A liveness sweep therefore read it as declared-but-unenforced and opened -a retirement candidate against it — which cost a full dispatch cycle before the -cross-repo read point was found and the candidate was closed premise-overtaken. -Nothing on the spec side recorded that liveness, so the next sweep would have -derived the same false candidate from the same absence. - -**The key is live**, re-verified at the objectui pin this repo builds against -(`.objectui-sha` = `82a94170c`) rather than taken from the card: - -- `packages/components/src/renderers/layout/containers.tsx:851-853` — - `PageAccordionRenderer` renders `{item.icon && }` - inside the `AccordionTrigger`, grouped with the label in the trigger's single - wrapping span. -- `containers.tsx:898` — `ComponentRegistry.register('accordion', …)` publishes - the key to the Studio block designer in the `items` input, documented as - `[{ label, icon?, collapsed?, children }]`. - -**Nothing about what parses changes.** The key was already declared and already -optional; this adds the prose that makes its liveness readable, and the test that -keeps it readable: - -- a `.describe()` naming the consumer behaviourally, in the file's house idiom — - the same shape `record:alert`'s own `icon` uses ("Read on this component — - contrast …"), with the file:line anchors and the measured pin in the docblock - above the key, where this file keeps them; -- an accept-pin asserting the key parses on a `page:accordion` item and survives - to the parsed output, that an undeclared sibling on the same item is still - refused (so the accept is not vacuous on a schema that stopped being strict), - and that the `.describe()` still names the consumer — deleting it is what - re-opens the false candidate, so it is pinned rather than left to review. - -The item `value` prescribed against one line above is the deliberate contrast: -the same renderer overwrites that key with `panel-`, and a read point is -precisely what separates the two verdicts. 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/action-onsuccess-navigation.md b/.changeset/action-onsuccess-navigation.md deleted file mode 100644 index a7f4d80a6b..0000000000 --- a/.changeset/action-onsuccess-navigation.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `ActionSchema.onSuccess` — post-success navigation for `api`/`script` actions, with `${result.*}` joining the navigate template's interpolation scope (#9566, #9474) - - - -The maintainer's 2026-08-18 ruling (recorded on #9566, mirrored on #9474) -declares ONE post-success navigation contract for both server-executing action -types instead of two per-type conventions: - -- `onSuccess: { navigate, openIn? }` — a strict object, read for - `type: 'api'` and `type: 'script'` only (a refinement refuses it on - `url`/`modal`/`flow`/`form`, where no success event exists for it to ride — - the ADR-0078 posture, same enforcement shape as the `body`-on-non-script - refinement). -- `navigate` is a route/URL template. Its documented interpolation scope is - `${param.*}` + `${ctx.*}` (existing) + **`${result.*}` — NEW: the action's - server response payload** (an `api` action's response body, a `script` - handler's return value), which is what makes "server clones a record → jump - to the new record" declarable: `navigate: '/apps/crm/tasks/${result.id}'`. - The interpolation ENGINE stays the renderer's (objectui `interpolateTarget`); - the spec records the contract. -- `openIn` is the closed enum `'self' | 'newTab'`, defaulting **`'self'`** - (materialized, the file's default convention) — no general navigation DSL. -- The shipped handler-return convention (`{ redirectUrl, openIn? }`, - objectui#2967/#2904) keeps its 17.0.0 semantics: absent `openIn` still means - new-tab (no silent behavior flip for existing handlers); a handler may return - `openIn: 'self'` explicitly. - -The console consumer is the downstream objectui half (SPA navigation branch, -`executeAPI` navigation handling, `${result.*}` interpolation), filed -Blocked-by these cards; the liveness ledger records the key at `planned` -strength with the amend-on-landing instruction. diff --git a/.changeset/action-predicate-sparse-face-guards.md b/.changeset/action-predicate-sparse-face-guards.md deleted file mode 100644 index ad077bc7f0..0000000000 --- a/.changeset/action-predicate-sparse-face-guards.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/platform-objects': patch -'@objectstack/plugin-approvals': patch ---- - -Guard every authored record-scoped action predicate for the sparse action face, so a list row that did not project the gated column no longer silently drops the button. - -An action's `visible` / `disabled` predicate binds whatever record the client already fetched — a record-detail read, or a list row carrying only the view's `$select` projection. That binding stays sparse by decision (it is the one record binding the platform does not make total), and CEL aborts the whole expression at key resolution when a key is absent. The abort is fail-closed, so the button is simply not offered — indistinguishable to the user from the gate having said no, and reported nowhere. - -Every authored predicate on `sys_user`, `sys_invitation`, `sys_member`, `sys_oauth_application` and `sys_approval_request` now opens each `record.*` read with `has()`. The guard is the minimal measured form per predicate, not one blanket rewrite: a bare equality against a literal needs `has()` alone, because CEL compares heterogeneously and answers `false` on a projected-null column rather than faulting. - -Two predicates change what a user sees, both on `sys_oauth_application`, whose `disabled` column is nullable upstream and therefore null on every application nobody has ever toggled: - -- `disable_oauth_application` was `!record.disabled`, which faulted on a projected-null row (`!` needs a bool) — so the Disable button was missing from every never-toggled application in the list. It is now `has(record.disabled) && record.disabled != true` and is offered. -- `enable_oauth_application` was `record.disabled`, which answered `null` rather than a boolean and left the decision to the renderer. It is now `has(record.disabled) && record.disabled == true`. - -`sys_approval_request`'s decision levers gate on the attached `record.viewer` block and traverse, so they are guarded at the leaf (`has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true`). Measured, that is the minimal safe form for a nested read: the canonical `has(x) && x != null` conjunction still faults when the block is present but the flag is absent or null, while a leaf `has()` subsumes the parent `!= null` half. Their intended fail-closed behaviour is unchanged — it is now a real `false` instead of an evaluation fault. diff --git a/.changeset/actions-door-demotes-author-codes.md b/.changeset/actions-door-demotes-author-codes.md deleted file mode 100644 index 198e9e88af..0000000000 --- a/.changeset/actions-door-demotes-author-codes.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/types": minor -"@objectstack/runtime": minor ---- - -`error.code` is a closed vocabulary at every door (#9106, maintainer ruling -2026-08-16): the runtime dispatcher's thrown-error exits -(`HttpDispatcher.errorFromThrown`, `dispatcher-plugin`'s `errorResponseBase`, -`endpoint-executor`'s `endpointErrorAnswer` — the actions door among them) now -serve the narrowed `code` the shared resolver (`resolveThrownHttpError`, -`@objectstack/types`) has always computed, exactly as the REST door has since -#8016. A thrown code that is not a member of `StandardErrorCode ∪ -ERROR_CODE_LEDGER` no longer reaches `error.code`. - -It is not dropped: `ApiErrorSchema` declares a new optional `declaredCode` -field — the open, author-authored channel — and the demoted spelling rides -there. Presence means demotion: the field is absent whenever the producer's -code is a vocabulary member (it is already in `error.code`) or the producer -declared none. The #7867 sandbox passthrough capability is preserved — a -metadata app's own thrown `.code` still crosses the QuickJS boundary and still -reaches the wire. - -For a metadata app that throws its own code (e.g. -`Object.assign(new Error('pick another'), { code: 'DUPLICATE' })` in an action -body) and reads it back from an actions-door failure: - -- FROM: `error.code === 'DUPLICATE'` -- TO: `error.code` is the closed member the status derives (e.g. - `VALIDATION_ERROR` on a 400) and `error.declaredCode === 'DUPLICATE'`. - One-line fix: branch on `error.declaredCode` for app-specific spellings; - branch on `error.code` for platform conditions. - -Platform producers are unaffected: every registered code reaches `error.code` -verbatim, as before (post-#8846 the dispatcher-vocabulary gate holds that set -registered). Measured before landing (the ruling's binding precondition): no -existing consumer of the actions door branches on author-authored strings in -`error.code`. - -`@objectstack/types` adds `demotedDeclaredCode(thrown)` — the one definition of -"which spelling a boundary surfaces beside the closed `code`". diff --git a/.changeset/actions-flow-dispatch-status-table.md b/.changeset/actions-flow-dispatch-status-table.md deleted file mode 100644 index b13cc27dfa..0000000000 --- a/.changeset/actions-flow-dispatch-status-table.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -`POST /api/v1/actions/:object/:action` answers the flow-dispatch status table instead of one blanket `400 FLOW_FAILED` (#9446). - -**What a caller sees differently.** A `type: 'flow'` action whose dispatch is REFUSED no longer reports a failed run. Three answers changed: - -| the flow behind the action | before | now | -|---|---|---| -| is not registered | `400` `FLOW_FAILED` | `404` `RESOURCE_NOT_FOUND` | -| is switched off | `400` `FLOW_FAILED` | `409` `FLOW_DISABLED` | -| has no `start` node | `400` `FLOW_FAILED` | `422` `FLOW_NO_START_NODE` | -| ran and was rejected | `400` `FLOW_FAILED` | `400` `FLOW_FAILED` (unchanged) | - -These are the same four rows `POST /api/v1/automation/:name/trigger` has answered since #9378 + #9415, and they now come from one shared definition both doors read, so the two cannot drift apart again. - -**Behaviourally breaking for a caller that branches on the status or the code.** Every one of these was a `400` before, so a caller treating `400` as "the run failed" was being told something false in three of the four cases: nothing had dispatched and no node had executed. A client that lumps all four together keeps working — they are all still refusals, all still `success: false` with no inner envelope — but one that reports "the flow failed" on a `400` should now distinguish. **Retry semantics differ per row**, which is the practical reason to: `409 FLOW_DISABLED` is reversible operational state (enable the flow and the identical request succeeds), while `404` and `422 FLOW_NO_START_NODE` are authoring defects that no retry fixes. `400 FLOW_FAILED` remains terminal, exactly as the console already treats it. - -**Unchanged on purpose.** A successful run still answers `200` with the single `data` wrap (#3962). The `400 FLOW_FAILED` message keeps its existing wording (`Flow '' failed: …`), which names the flow the action dispatches — the trigger route's URL carries that name and this route's does not. A `success: false` result the automation engine did not classify still refuses with `400 FLOW_FAILED` rather than falling back to `200 {success:true,data:{success:false}}` — the double envelope #3962 removed from this route. - -**Not in scope.** Declared endpoints (`type: 'flow'` endpoints, `endpoint-executor.ts`) still answer `200` for every outcome. That door converges in its own change (#9462), where the envelope flip is a breaking change for consumers of the current double envelope and is sequenced against them. 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/adr-0057-d10-citation-attributive.md b/.changeset/adr-0057-d10-citation-attributive.md deleted file mode 100644 index 328c7fad1b..0000000000 --- a/.changeset/adr-0057-d10-citation-attributive.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -docs(lint): the `readonlyWhen` field-rule diagnostic no longer cites `ADR-0057 D10` (#9255) - -The author-visible consequence text for a faulting `readonlyWhen` predicate said -"Per ADR-0057 D10 the server is the one that decides". The rule it states is -correct and unchanged — the server locks the field while the form still renders -it editable — but the citation does not resolve: `D10` of the ERP-authorization -`ADR-0057` decides Setup-nav capability surfacing, and the other `ADR-0057` -(system data lifecycle) carries no D-numbered decisions at all. An author who -followed the anchor landed on an unrelated decision and had no way to tell -whether the code or their search was wrong. - -The diagnostic now states the rule on its own authority, which is where it -always rested. No behaviour, no message semantics and no rule changed — only -the traceability claim. Recording the rule as an actual decision is tracked -separately in #9628. 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/anonymous-deny-401-code-key.md b/.changeset/anonymous-deny-401-code-key.md deleted file mode 100644 index f6430b43fc..0000000000 --- a/.changeset/anonymous-deny-401-code-key.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/core": minor ---- - -feat(security): the REST 401 anonymous-deny body carries `code: "UNAUTHENTICATED"` alongside the existing `error` / `message` keys (#9487) - -Every other REST error family answers `{ error, code }`, with the machine code -in `code` — the 401 family was the one outlier, answering -`{ error: "UNAUTHENTICATED", message }` with no `code` key at all. A client -keying on `body.code` (the shape the other families teach, and the first read -of `@objectstack/client`'s `err.code`) read `undefined` for every -authentication failure. - -`ANONYMOUS_DENY_BODY` now carries `code: "UNAUTHENTICATED"` as well. -**Additive only** (maintainer-ruled): no key is removed or moved — `error` -keeps holding the same code value it always has, so every existing reader -keeps working. The wire effect surfaces through `@objectstack/rest`'s -`enforceAuth`, which writes this constant verbatim on every `/data`, `/meta` -and `/reports` 401. This does not settle ADR-0112 D5 (flat vs nested envelope -convergence); both declared envelope families are unchanged in kind. 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/arm-platform-migrations-self-hosted.md b/.changeset/arm-platform-migrations-self-hosted.md deleted file mode 100644 index 75662186f7..0000000000 --- a/.changeset/arm-platform-migrations-self-hosted.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor -"@objectstack/objectql": minor -"@objectstack/runtime": minor -"@objectstack/cli": patch ---- - -fix(metadata-protocol): arm the three `kernel:ready` platform-table migrations on a self-hosted boot, and keep the read-only CLI commands read-only (#9380) - - - -`assembleMetadataProtocol` arms three `kernel:ready` migrations — #5839's -`sys_view_definition` active-row index, #8629's `sys_setting` row-identity -index, and #8686's seed/API tenancy backfill — behind one gate whose own comment -states the intent: *"platform / standalone kernels own their local sys_metadata; -per-project (cloud) kernels source metadata from the control plane and must NOT -provision these tables locally."* So standalone was always meant to be on the -INSIDE of that gate. - -It never was. The gate **deduced** ownership from `environmentId === undefined`, -and `runtime/src/standalone-stack.ts` stamps `'proj_local'` on every boot — so -the block never ran on a self-hosted install at all. #8686's own header calls -its `kernel:ready` half the one that "repairs an install that is ALREADY in that -state, which covers every existing deployment"; on self-hosted it covered none, -and those installs kept minting duplicate business identifiers. - -**The fix is a declaration, not a wider deduction.** `environmentId` is a -row-scoping key, not a topology signal — the same lesson `authoringChannel` -already records one field above it in the same options bag. A new optional -`runPlatformMigrations` is threaded from the host that knows the answer down to -the one assembly both protocol mounts share: - -- `AssembleMetadataProtocolOptions` / `MetadataProtocolPluginOptions` / - `ObjectQLPluginOptions` gain `runPlatformMigrations?: boolean`; -- `createStandaloneStack` gains the same key and **defaults it to `true`** — a - standalone kernel owns its local platform tables, whatever environment id it - stamps rows with; -- the predicate is exported as `shouldRunPlatformMigrations(environmentId, - declared)` so the default lives in exactly one place. - -**Undeclared means unchanged.** The default is `environmentId === undefined`, -the historical deduction, so every caller that does not declare — including -cloud's per-project kernels (`createMetadataProtocolPlugin({ environmentId })`) -and the control-plane assembly (`createMetadataProtocolPlugin()`) — keeps -today's behaviour exactly. - -**The read-only contract is preserved, and not by keying on deferral.** The -CLI's one-shot boot funnel (`bootSchemaStack`) declares -`runPlatformMigrations: false` for every `os migrate *` / `os meta *` command. -Keying it on `deferSchemaDdl` would have covered only `os migrate plan` and -`os migrate duplicates`; `os migrate summary-nulls`, `value-shapes`, -`recorded-by`, `resume`, `files-to-references` and `os migrate meta` all boot -**non-deferred** and are still dry-run-by-default ("a dry run writes NOTHING"), -so that half would have quietly repaired rows behind a report. The serving boots -— `os dev`, `os serve`, `os start` — do not come through that funnel and take -the default, which is where an install now gets repaired. - -Proven on real kernels over a real SQLite file carrying the real #8686 damage, -not on the predicate: the serving boot merges the split counter and adopts the -movable seed row while leaving the colliding one reported-not-renumbered; the -deferred and non-deferred one-shot boots both leave the data untouched; and a -per-project kernel assembled cloud's way still repairs nothing. -`os migrate duplicates`' own byte-identical-after-run pin -(`duplicates.integration.test.ts`) still passes unchanged. diff --git a/.changeset/artifact-path-child-env-internal-channel.md b/.changeset/artifact-path-child-env-internal-channel.md deleted file mode 100644 index b7a401f921..0000000000 --- a/.changeset/artifact-path-child-env-internal-channel.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli): `os start` / `os dev` stop writing `OS_ARTIFACT_PATH` into the child `serve` environment — the CLI's own plumbing moves to an internal channel (#8985) - -`os start` and `os dev` are supervisors: each resolves an artifact, then spawns -`os serve` to boot it. Both handed the resolved path down by writing -**`OS_ARTIFACT_PATH`** into the child environment — the same variable an operator -sets to name an artifact. `dev` wrote it unconditionally; `start` wrote it -whenever it had resolved anything and no `OS_ARTIFACT_URL` was in play. Both -writes happen **before** the downstream `objectstack.config.ts` is evaluated. - -So inside any config, that variable was set on **every** boot, including boots -where no operator had ever mentioned it — measured from the shipped EE image -with its own `ENV` deliberately deleted: `os start` still printed -`Artifact: dist/objectstack.json` and handed that path down. A config could not -answer *"did a human ask for this, or did the CLI put it here?"* - -The resolved path now travels on **`OS_INTERNAL_ARTIFACT_PATH`**, a channel the -CLI owns both ends of (`packages/cli/src/utils/internal-artifact-channel.ts`), -and the property downstream consumers need is restored: - -> **the presence of `OS_ARTIFACT_PATH` in a config's environment means an -> operator set it.** - -**Nothing about resolution changed.** Each command's ladder resolves in the -parent exactly as before, and `serve` reads the new channel strictly between the -reference and the operator knob: - -``` ---artifact > OS_ARTIFACT_URL > OS_INTERNAL_ARTIFACT_PATH > OS_ARTIFACT_PATH > /dist/objectstack.json -``` - -That position is what preserves today's answers in both directions. It beats -`OS_ARTIFACT_PATH` because `os start --artifact X` run with an operator's -`OS_ARTIFACT_PATH=Y` exported boots **X** today — the parent used to overwrite -the variable on the way down, and now inherits it untouched. It loses to -`OS_ARTIFACT_URL` because `os dev` writes the channel unconditionally, as it -wrote the old variable unconditionally, and the reference has always outranked -the path. - -Two further behaviours are unchanged and now pinned rather than incidental: -`start` still refuses to set `OS_BOOT_EMPTY` when a reference is driving the -boot (an unreachable artifact host stays a loud refusal instead of a silently -empty platform), and a resolved-but-missing artifact is still "named" to -`resolveDefaultArtifactPath`, so it fails loudly rather than booting empty. - -**If you depended on the old side effect** — a config reading -`process.env.OS_ARTIFACT_PATH` and expecting the CLI to have populated it — set -the variable yourself, or read the artifact from the config's own inputs. -`OS_ARTIFACT_PATH` remains a fully supported operator knob on the exact rung it -has always occupied; the CLI simply no longer manufactures it on your behalf. -`OS_INTERNAL_ARTIFACT_PATH` is not a supported knob and is deliberately absent -from the environment-variable reference. 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-route-capability-gap-refused.md b/.changeset/audit-route-capability-gap-refused.md deleted file mode 100644 index d82c29370d..0000000000 --- a/.changeset/audit-route-capability-gap-refused.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): a missing `auditMetaItem` capability is refused, not answered as "this item has no audit trail" (#9426) - -`GET /api/v1/meta/:type/:name/audit` feature-detects `auditMetaItem` on the -resolved protocol. When the method was absent the route answered -`200 { events: [] }` — so a **capability gap** reached the wire as the statement -**"the audit trail was read and this item has no entries"**. - -Per ADR-0110 D3 those are different facts, and this one is a **compliance** -surface. The route's own comment says it exists so Studio's 审计日志 / Audit log -tab can show "who tried what and whether a lock blocked it". An empty answer -there reads as *nobody touched this item* — precisely the claim a compliance -reader must not be given on false pretenses. - -The branch now refuses: - -``` -501 { error: { code: 'NOT_IMPLEMENTED', - message: 'protocol.auditMetaItem() is not available in this kernel' } } -``` - -— the ADR-0112 nested envelope the sibling `/meta` 501 refusals converged on -(#7035), so `body.error.code` is readable by the same one line of consumer code -that already reads the others. This is the last limb in `rest-server.ts` that -answered a capability gap with a well-formed empty collection; #9326 / PR #9425 -fixed the `findReferencesToMeta` twin, and five siblings already refused. - -**The unprovisioned-table answer is unchanged, and the two were never the same -path.** The route's header comment promises "Empty array on environments where -the table is not yet provisioned" — that condition is handled one layer down, in -`ObjectStackProtocolImplementation.auditMetaItem`, whose `catch` returns -`{ events: [] }` after a `console.warn`. That path requires the method to exist -and to be called; this branch returns before the call. Separate frames, separate -packages. - -**Does any caller's observed response change? Yes, on one deployment shape, and -only there.** A protocol that *has* the method is untouched: an empty trail and a -populated one both still pass through verbatim as `200`. What changes is the -answer given when the protocol has no such method — previously `200` with an -empty list, now `501`. No assembly in this repo produces such a protocol today: -`ObjectStackProtocolImplementation` is the only implementation registered under -the `protocol` service and it defines the method unconditionally. The branch is -reachable rather than dead because `auditMetaItem` is **not** a member of -`RestProtocol` (`= DataProtocol & MetadataProtocol`) and is not declared in -`@objectstack/spec` at all — it is an ADR-0076 D9 server-only extension reached -through a runtime cast. A host that implements the declared contract exactly, or -that points `protocolServiceName` at its own service, is a *conforming* -deployment that lands on this branch with no type error. - -Refusing at the route rather than asserting at assembly is deliberate: a -boot-time assertion would promote an undeclared optional extension into a -required one, which is a contract decision for `@objectstack/spec` rather than a -route one, and it would reject the partial protocol doubles that legitimately -exist today. 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/audit-tenant-fallback-reads-organization-id.md b/.changeset/audit-tenant-fallback-reads-organization-id.md deleted file mode 100644 index d4676a799f..0000000000 --- a/.changeset/audit-tenant-fallback-reads-organization-id.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/plugin-audit": patch ---- - -fix(audit): the NULL-tenant guard on audit rows reads the session key the engine actually emits (#9516) - -`writeAudit`'s organization fallback read `sess.tenantId`. That key does not -exist on the hook session: `ObjectQL.buildSession` constructs it as an object -literal with a fixed key set (`userId`, `organizationId`, `positions`, -`accessToken`, plus conditional flags) and no spread, and the deprecated -`session.tenantId` alias (#3280) was removed repo-wide in the v11 major -(#3290). The arm therefore resolved to `undefined`, which made the guard it -belongs to unable to fire. - -That guard is load-bearing. Its own comment states the consequence: an audit -row must never be written with `organization_id = NULL`, or the SecurityPlugin's -RLS predicate hides it from everyone forever while the write reports success. -The two cases the fallback covers are exactly the ones where the record cannot -supply an organization — an object with no organization column at all -(single-tenant stacks, ADR-0066 platform-global objects) and a row whose -organization column is NULL or empty. On both, the row was stamped -`organization_id: null` and became permanently invisible in the audit log UI. -Nothing reported it: every `sys_audit_log` field is `readonly: true` so -`validateRecord` skips it, and the write path is wrapped in swallow-and-report. - -Both readers of the removed alias in this package now read -`sess.organizationId`. Precedence is unchanged at both sites: the audited -record's own organization still wins over the acting session (#8707, honouring -the ruling on #8287), and the `@mention` notification scope stays session-first -— #8707's reasoning is about an audit row read through the record's tenant -wall, which a notification is not, so only the key changed there. - -Deployments on a single-tenant stack, and any multi-tenant deployment auditing -a platform-global object or a row with an empty organization column, stop -accumulating audit rows that no one can read. Rows already written with a NULL -organization are not repaired by this change. diff --git a/.changeset/auth-email-deployment-locale.md b/.changeset/auth-email-deployment-locale.md deleted file mode 100644 index d44ae70ec9..0000000000 --- a/.changeset/auth-email-deployment-locale.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -"@objectstack/plugin-email": patch -"@objectstack/plugin-auth": patch ---- - -fix(auth): auth emails follow the deployment locale — all five remaining templates localized, and every send names a locale (#8195) - - - -Outbound auth mail was English-only on a platform that supports four locales and -whose UI already switches between them. Two facts made every non-`en-US` template -row unreachable through the platform's own send path: - -- **no auth send named a locale** — all five `sendTemplate` calls in - `auth-manager.ts` omitted it, so `EmailService`'s ladder resolved - `DEFAULT_TEMPLATE_LOCALE` (`en-US`) every time; -- **only one auth template had non-`en-US` rows at all** — - `auth.email_change_notice` shipped four locales with #8019; the other five were - `en-US`-only. - -Both halves land together, and that is the substance of the fix rather than its -packaging. Shipping the resolution alone was measured to be **worse than the -English status quo**: the ladder falls back to the **en-US row body** on a miss -while `const locale = preferred || row.locale` still hands the caller's locale to -the render filters — so a zh-CN deployment would have received English prose -carrying zh-CN-formatted dates and numbers *inside a single message*, which is -precisely the artefact the row-locale authority (#7801) exists to prevent. - -**Templates.** `auth.password_reset`, `auth.verify_email`, `auth.magic_link`, -`auth.invitation` and `auth.two_factor_otp` each gain `zh-CN`, `ja-JP` and -`es-ES` rows — 15 new rows, seeded through `BUILTIN_AUTH_TEMPLATES` so they are -selectable rather than merely exported. Each localized row also carries a -localized **footer**: `wrap()` supplies an English one by default, so a row that -forgets it renders fluently translated prose under an English sign-off. - -**Resolution.** Per the maintainer ruling of 2026-08-13, the recipient locale is -the **deployment default**, read from `II18nService.getDefaultLocale()` and -resolved at the plugin layer — `AuthPlugin` pushes it into -`AuthManager.setDefaultEmailLocale()` on `kernel:ready`, exactly as it already -pushes the auth **SMS** locale (#2815). `Accept-Language` is rejected: auth mail -is routinely sent outside the triggering request (invitations, admin-initiated -resets), so a per-device header is the wrong authority. A per-user -`sys_user.locale` column is deferred until there is measured pull for one; when -it arrives it layers on top of this as an override. - -**One spelling gap had to be bridged**, and it is measured rather than assumed: -`getDefaultLocale()` carries the message-**catalog** language, whose English -spelling is the bare `en` (`FileI18nAdapter`: `options.defaultLocale ?? 'en'`), -while template rows are keyed `en-US` and `SendTemplateInput.locale` is -documented as matched exactly, with "no language-only prefix matching". Passed -through raw, the commonest deployment of all would miss every row and lean on the -en-US fallback while telling the render filters `en`. `normalizeAuthEmailLocale` -therefore promotes a **bare language subtag** to the regional row the platform -ships (`en` ⇒ `en-US`, `zh` ⇒ `zh-CN`, …) and passes everything else through -untouched — an unshipped regional tag such as `en-GB` or `fr-FR` may well be a -tenant's own overlay row, and swallowing it would re-create this very bug for the -fifth locale onward. - -**Nothing changes for an unconfigured deployment.** With no i18n service -registered, or none declaring `getDefaultLocale`, no `locale` key is passed at -all and the ladder resolves its documented `en-US` default exactly as before. 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/authz-transport-wired-requires-enforced-row.md b/.changeset/authz-transport-wired-requires-enforced-row.md deleted file mode 100644 index 8200879f5a..0000000000 --- a/.changeset/authz-transport-wired-requires-enforced-row.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/dogfood": patch ---- - -test(qa): a wired realtime transport can no longer be signed off by the row that records the absence of authorization (#9083) - -The ADR-0056 D10 authz conformance matrix carries a tripwire note promising -that wiring an end-user realtime transport reds CI "until this row is upgraded -with the enforcement site." The gate did not hold that out. `checkLedger` -requires an `enforcement` site only when `state === 'enforced'`, while a row's -`covers` keys classify a discovered surface **regardless of state** — so the -shortest path from red back to green was to append the tripwire key to the -`experimental` `realtime-delivery-authz` row, whose own summary records that -realtime fan-out has **NO** per-recipient authorization. - -Measured on `origin/main` before the fix, both legs reproduced from the filing: -wiring `new EventSource('/api/v1/stream')` into `packages/client/src/realtime-api.ts` -fails 2 of 15 cases as `UNCLASSIFIED surface — … realtime:client/realtime-api.ts:transport(TRANSPORT-WIRED)`; -appending that one key to the experimental row — with the transport still wired -and zero authorization written — returns 15/15 green. The `removed` state was -measured to admit the identical exit, so the rule keys on **not `enforced`** -rather than on `experimental`. - -`checkTransportWiredAdmission` in `authz-conformance.test.ts` now refuses a -`TRANSPORT-WIRED` key covered by any row that is not `enforced`, and -`checkLedger`'s existing enforced-has-site invariant supplies the other half — -the two compose into the promise the note makes, so flipping a row's state -without writing the site is refused as well. The rule lives beside the probe -table rather than in the shared ADR-0060 `checkLedger` helper on purpose: -`TRANSPORT-WIRED` is this ledger's own vocabulary, and five other conformance -ledgers share that helper without having transport tripwires. Tripwire keys are -now minted through one `tripwireKey()` helper so the marker cannot drift out of -the rule's sight (the keys themselves are byte-identical to before), and every -assertion in the file drives the composed gate instead of `checkLedger`. - -The matrix note, the `covers` field TSDoc and both file headers were corrected -to describe the gate that actually ships — the declared-≠-enforced defect here -was in the *note*, so leaving it in place would only have moved the -discrepancy. Six cases pin the new rule, including both reverse-verification -legs and a positive control proving an `enforced` row naming its site still -admits the key; each refusal case also asserts that bare `checkLedger` accepts -the same ledger, so none can pass for an unrelated reason. Gate behaviour only -— no runtime, spec or product surface changes, and no matrix row changed state. diff --git a/.changeset/authz-tripwire-dispatcher-plugin-boundary.md b/.changeset/authz-tripwire-dispatcher-plugin-boundary.md deleted file mode 100644 index c80e02cf86..0000000000 --- a/.changeset/authz-tripwire-dispatcher-plugin-boundary.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/dogfood": patch ---- - -docs(qa): record `dispatcher-plugin.ts` as deliberately outside the #2992 transport tripwire set, with the reason (#9410) - -`packages/runtime/src/dispatcher-plugin.ts` has both properties that make a file -a plausible landing site for a realtime transport, and neither the conformance -test nor the protocol page said anything about it. It **mounts routes** — -`/actions`, `/automation` and `/packages`, the registration path separate from -the `@objectstack/rest` one — and it **already writes SSE**: two -`text/event-stream` sites with `no-cache` and `keep-alive`, working plumbing an -agent could extend without writing any new transport mechanics. None of the five -`#2992` / ADR-0096 D4 transport tripwires watch it, so a subscribe/fan-out -transport wired there mints no `TRANSPORT-WIRED` key, produces no UNCLASSIFIED -surface and reds no build. The protocol page stated the general limitation ("a -transport wired outside the watched files produces no key and no failure") -without naming the specific already-SSE-capable file sitting inside it. - -**This change is a recording. It changes no behaviour**: no probe is added, no -key is minted, and no matrix row is written for the two existing sites. - -The reason is recorded because it is the part a reader cannot re-derive cheaply. -Those two `text/event-stream` sites are **per-request AI response streaming, not -realtime subscription fan-out**: each drains one `AsyncIterable` that the route -handler itself returned into that same request's response body and then calls -`res.end()` — the second site's own source comment names its producer as the AI -routes. No subscriber is registered, no event is delivered to a *set* of -recipients, and the file carries no upgrade handler, no subscribe registration -and no realtime-service call. Watching it with the existing mechanics pattern -would therefore mint a key on day one for a surface that is not the hazard -`#2992` is about, leaving only two exits: classify two non-realtime sites in the -matrix vocabulary, or weaken the pattern. Neither is acceptable, so the file -stays out and the boundary is written down instead. - -It is written in the two places a reader actually lands. In -`authz-conformance.test.ts` the note closes the tripwire probe list, so a reader -who has just finished enumerating the watched set reads the set's boundary in -the same breath — beside, and explicitly distinguished from, the pre-existing -`#5519` mention of the same file, which is about anonymous gates on the mounted -routes and is a different point. In `realtime-protocol.mdx` it extends the -identity-admission callout at the exact sentence that states the general -limitation. - -The exclusion is drawn on **fan-out, not on the SSE content type**, and both -records say so: wiring an upgrade handler, a subscribe registration or a -realtime-service call into that file puts it back inside the hazard while the -recorded boundary still claims otherwise. Promoting it into the tripwire -population with such a fan-out-specific marker is written into #8347's -acceptance as a precondition of the WebSocket/SSE transport landing, so the -design effort is spent when the hazard becomes real rather than now. diff --git a/.changeset/automation-execute-terminal-messages.md b/.changeset/automation-execute-terminal-messages.md deleted file mode 100644 index 782446077c..0000000000 --- a/.changeset/automation-execute-terminal-messages.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -fix(service-automation): a triggered run carries the flow author's `successMessage` / `errorMessage` — `execute()` and both retry exits, symmetric with `resume()` (#9414) - -`AutomationResult` declares `successMessage` / `errorMessage` as a general -terminal-result feature — *"Friendly terminal messages copied from the flow -definition (`flow.successMessage` / `flow.errorMessage`) … `successMessage` is -set on terminal success, `errorMessage` on failure."* One producer honoured it. -`resumeInternal` set both on its terminal returns; `execute()` set neither, on -either exit, and neither did `executeWithoutRetry` or `retryExecution`. - -So a flow's own words reached a caller **only if the run happened to pause and -be resumed**. A flow dispatched straight through -`POST /api/v1/automation/:name/trigger` — or the legacy `trigger/:name` that -`client.automation.trigger()` calls — carried nothing, though the flow declared -the text and the contract said it was set. One declaration, two behaviours -decided by *route* rather than by authoring. - -**The consumer was already there and already reading.** The trigger route -carries `errorMessage` into `error.details.errorMessage` (#9413), which is the -one place the console reads it from (objectui `flowResponse.ts`, #4899) — and on -the trigger path it was **always absent at the source**, so every non-screen flow -showed the raw node error instead of the sentence its author wrote. - -Four terminal exits now produce the pair, which is every exit a triggered run can -leave through: - -- `execute()` terminal success → `successMessage`; terminal failure → - `errorMessage`, **beside** the raw `error` rather than instead of it (the - transport folds `error` into the ADR-0112 message and carries the author's text - in `details`). -- `executeWithoutRetry()` — both exits. `retryExecution` returns this result - verbatim when a later attempt succeeds, so without it `successMessage` would - depend on *which attempt* happened to work. -- `retryExecution()`'s **exhausted** exit, which is a different exit from - `execute()`'s own failure return — a flow under `errorHandling.strategy: - 'retry'` never reaches that one. A repair stopping at `execute()` would have - left the message missing for exactly the runs most likely to need it. - -**Nothing else gained a message, deliberately.** The paused return is not -terminal; the skip exits (`condition_not_met`, `reentrancy_loop_guard`) return -`success: true` for a run that executed no node; the never-dispatched exits -(flow not found / disabled / no start node) have no lifecycle verdict at all and -must not acquire a second channel implying one. Those boundaries are pinned, not -just described. - -No new keys and no contract edit — the pair was declared, documented and -consumed already; this is the production half catching up (ADR-0049 -enforce-or-remove, restoration direction). diff --git a/.changeset/automation-readme-link-labels.md b/.changeset/automation-readme-link-labels.md deleted file mode 100644 index 18435d556e..0000000000 --- a/.changeset/automation-readme-link-labels.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -`service-automation`'s README labels its two docs links with the pages they land on (#9668) - -Two entries in the README's See Also list named artifacts that are not what the link -resolves to. `Flow Builder Guide` lands on the Automation **section index** -(`content/docs/automation/index.mdx`, `title: Automation`); `Trigger Reference` lands on -the automation **schema** reference index (`content/docs/references/automation/index.mdx`, -`title: Automation Protocol`), which lists every automation schema — approval, flow, -state-machine, webhook and eleven more — not triggers. Both destinations resolve and both -are the right section-level target for a package README, so the mismatch is on the label -axis, not the destination axis, and only the labels changed. - -Each new label is the destination page's own `title` frontmatter, with a gloss compressed -from that page's own `description`, so the label is checkable against the page rather than -invented. **No URL changed**, and in particular the plausible-looking -`references/studio/flow-builder` was not adopted: it is a **Studio** reference, not the -automation service's. - -The third docs link in this README — `Flows`, at the end of the flow-node section — was -verified and left alone. It points at a specific page (`content/docs/automation/flows.mdx`, -`title: Flow Metadata`), and that page does carry the per-node `config` reference, loop and -parallel containers, subflows, waits and error handling that the sentence promises. - -Documentation only: no API, behaviour or type surface changes. diff --git a/.changeset/automation-resume-envelope-closed-set.md b/.changeset/automation-resume-envelope-closed-set.md deleted file mode 100644 index 78ee027f21..0000000000 --- a/.changeset/automation-resume-envelope-closed-set.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -**BREAKING** — `POST /api/v1/automation/:name/runs/:runId/resume` refuses a request -body carrying an unknown top-level key. The resume body's outer envelope is now a -closed set: exactly `inputs`, `variables`, `output`, `branchLabel`. - -Until now the route read the keys it knows and silently ignored the rest, so a body -like `{"nodeId":"ask","values":{...}}` — no key of which the route reads — answered -HTTP 200 `success:true` with the screen submission treated as empty: the run -completed and the submitted value never reached the flow. A caller that guessed -`values` for the key the route spells `inputs` got silence instead of a correction. - -What changes on the wire: - -- **A body with any unknown top-level key ⇒ `400` with `error.code: - 'VALIDATION_FAILED'`.** The message names the offending key(s) and the accepted - set; `error.details.fields[]` carries one `unknown_field` entry per offending key. - The request never reaches the flow engine, the suspension is untouched, and the - same request with a corrected body is expected to succeed — this refusal sits on - the retryable side beside `INVALID_SIGNAL` and `INVALID_SCREEN_INPUT`, and is - deliberately not `FLOW_FAILED` (which the console treats as terminal, because it - means the engine consumed the suspension and the run actually ran). -- **Unchanged:** a body made only of accepted keys behaves exactly as before, - including an empty body (a legal empty submission for a screen whose declared - fields are all optional). The signal is still assembled field-by-field — never a - body spread — so the service-authority marker stays unforgeable. - -Any client already sending only the documented keys is unaffected. A client sending -extra keys alongside a correct `inputs` now gets the located 400 above instead of -having the extras silently dropped. - - diff --git a/.changeset/automation-resume-status-unification.md b/.changeset/automation-resume-status-unification.md deleted file mode 100644 index ee207898fd..0000000000 --- a/.changeset/automation-resume-status-unification.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -'@objectstack/service-automation': minor -'@objectstack/runtime': minor -'@objectstack/client': minor ---- - -**BREAKING** — `POST /api/v1/automation/:name/runs/:runId/resume` answers real HTTP -status codes for a failed run instead of HTTP 200 wrapping an inner `{success: false}`. - -Until now a screen flow driven to a server-side node failure answered: - -``` -HTTP 200 -{"success":true,"data":{"success":false,"error":"Node 'create_opportunity' failed: …"}} -``` - -The run genuinely failed; the transport reported success. A scripted or integration -caller that branches on the HTTP status alone read a failed run as a successful one. -This applies to the resume route the ruling for `/actions` (business failures must not -ride HTTP 200 inside a double envelope), which every other automation refusal on this -route already followed. - -What changes on the wire: - -- **A run that resumed and then failed ⇒ `400` with `error.code: 'FLOW_FAILED'`.** The - node failure stays the human-readable `error.message`. The flow author's own - `errorMessage` travels in `error.details.errorMessage` — one documented location, the - same one the console reads — and the run's per-node `summary` in - `error.details.summary`. `durationMs` is no longer carried on this response. -- **A stale suspension ⇒ `404`.** The flow the run belongs to was deregistered, or the - node it was parked on was edited away under a live pause. Nothing ran and the pause can - never continue, so this is reported as terminal rather than as a business rejection. - The engine now classifies both cases as `RUN_NOT_FOUND`; the message names which one. -- **Unchanged:** every refusal that leaves the suspension intact keeps its own code and - stays retryable — `PERMISSION_DENIED` (403), `INVALID_SIGNAL` / - `INVALID_SCREEN_INPUT` (400), `RESUME_IN_PROGRESS` (409), `STORE_UNAVAILABLE` (503) — - and a resume that pauses again still answers 200 with the next screen. - -**`@objectstack/client`:** `client.automation.resume()` and -`client.project(id).automation.resume()` now **reject** on a failed run instead of -resolving with `{success: false, error, summary}` — the SDK throws on every non-2xx -before unwrapping. Callers that inspected the resolved value must move to a `catch`: - -```ts -try { - await client.automation.resume(flow, runId, { inputs }); -} catch (err: any) { - err.code; // 'FLOW_FAILED' (400) — the run ran and failed - err.httpStatus; // 400 | 404 | 403 | 409 | 503 - err.message; // the node failure, verbatim - err.details?.errorMessage; // the flow author's own message, when the flow declares one -} -``` - -Raw-HTTP callers that treated `2xx` as success and never opened the inner envelope now -see the failure they were already being told about, one level up. - - diff --git a/.changeset/automation-resume-value-shape-refused.md b/.changeset/automation-resume-value-shape-refused.md deleted file mode 100644 index 9afcfd9b32..0000000000 --- a/.changeset/automation-resume-value-shape-refused.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -**BREAKING** — `POST /api/v1/automation/:name/runs/:runId/resume` refuses an accepted -key carrying a value of the wrong TYPE, and refuses a request body that is not a JSON -object. This is the value axis of the closed envelope the same route already applies to -its KEYS. - -Until now the route type-guarded each accepted key and silently skipped whatever failed -the guard, so `{"inputs":"a string"}` passed the closed key set (the key IS accepted), -lost its value, and answered HTTP 200 `success:true` with the submission treated as -empty: the run completed and the caller was told its screen input landed when nothing -did. Identical for `{"output":42}`, `{"branchLabel":7}`, a JSON string/number/boolean -body, and an empty-array body. - -What changes on the wire: - -- **`inputs` / `variables` / `output` must each be a JSON object ⇒ anything else is - `400` with `error.code: 'VALIDATION_FAILED'`.** `error.details.fields[]` carries one - `invalid_type` entry per offending key, and both the entry and the message name the - key and the expected type (plus the type actually received). `null` and an array are - refused too — the engine's `ResumeSignal` contract types these as - `Record`, which excludes both, and an array used to be forwarded to a - service whose own contract rejects it. -- **`branchLabel` must be a JSON string ⇒ anything else is the same located `400`.** -- **A body that is not a JSON object ⇒ the same `400`, located at `(body)`**, naming the - accepted keys. That covers a JSON string, number or boolean body, and an array — - including the empty array, which previously slipped past the key check because it has - no keys to be unknown. -- Every refusal happens **before** the flow engine is consulted, so the suspension is - untouched and the same request with a corrected body is expected to succeed. Like the - unknown-key refusal it sits on the retryable side beside `INVALID_SIGNAL` and - `INVALID_SCREEN_INPUT`, and is deliberately not `FLOW_FAILED` (which the console - treats as terminal, because it means the engine consumed the suspension and ran). -- **Unchanged:** every submission that was already well-typed behaves exactly as before, - with byte-identical arguments at the service — all four accepted keys, the `variables` - alias, `inputs` winning when both are sent, empty objects, an empty-string - `branchLabel`, and the bodyless resume (`{}` / absent / `null` body), which stays a - legal empty submission. The inner bag is still forwarded verbatim for the engine to - judge — reserved-name and declared-field verdicts did not move into the transport. The - signal is still assembled field-by-field, never a body spread, so the - service-authority marker stays unforgeable. - -A key spelled with an `undefined` value counts as absent rather than mis-shaped: -`JSON.stringify` drops such a key, so no HTTP caller can produce one, and the in-process -spelling `{ inputs: maybeUndefined }` means "no inputs". - -Any client already sending well-typed values is unaffected. A client sending a -mis-shaped value now gets the located 400 above instead of a 200 reporting success on a -submission that was thrown away. - - diff --git a/.changeset/automation-trigger-refusal-codes.md b/.changeset/automation-trigger-refusal-codes.md deleted file mode 100644 index add363a291..0000000000 --- a/.changeset/automation-trigger-refusal-codes.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-automation': minor -'@objectstack/runtime': minor -'@objectstack/client': minor ---- - -**BREAKING** — the automation `trigger` routes now answer **409** for a disabled -flow and **422** for a flow whose definition has no start node, instead of HTTP -200 wrapping an inner `{success: false}`. - -This finishes the migration the previous release started. That changeset flipped -two of the four outcomes and said of the other two: - -> **Also unchanged, pending a ruling:** a DISABLED flow and one with no start -> node still answer 200 with the inner failure. Both are exits that never -> dispatched anything, and telling them apart needs a producer-side -> classification the closed `AutomationResult.code` union cannot yet express. - -That is the paragraph this change resolves. The union was widened deliberately — -two new members, with measured need — rather than the transport guessing from -message text or re-implementing the engine's enable-state policy. - -`POST /api/v1/automation/:name/trigger` and the legacy -`POST /api/v1/automation/trigger/:name` now answer, in full: - -| Status | `error.code` | The run | -|:---|:---|:---| -| `404` | — | never dispatched: no such flow | -| `409` | `FLOW_DISABLED` | never dispatched: the flow is switched off | -| `422` | `FLOW_NO_START_NODE` | never dispatched: the definition has no `start` node | -| `400` | `FLOW_FAILED` | RAN, and was rejected | -| `200` | — | succeeded, or PAUSED at a screen node — a pause is not a failure | - -The three refusals report no run because none exists: no node executed and -nothing was written. Only `400` describes a run, and only it carries -`error.details.summary` / `error.details.errorMessage`. - -Why two statuses and not one: a disabled flow is reversible operational state — -enable it and the identical request succeeds, which is what `409` means. A flow -with no start node cannot be executed as stored, and no retry helps, which is -what `422` means. Collapsing them would tell an operator to flip a switch that -will not help. - -**`@objectstack/spec`:** `AutomationResult.code` gains `'FLOW_DISABLED'` and -`'FLOW_NO_START_NODE'`. The union stays closed; these are trigger-time refusals -classified *before* dispatch, documented as a group distinct from the existing -resume-refusal members. Both are registered in the ADR-0112 error-code ledger. - -**`@objectstack/service-automation`:** `execute()` stamps the matching `code` on -its disabled-flow and no-start-node exits. They continue to carry **no** -`status` — that absence is what lets a transport tell a never-dispatched exit -from a run that dispatched and failed (`status: 'failed'`) without inspecting -`summary`, `durationMs` or the message. - -**`@objectstack/client`:** `client.automation.trigger()`, `.execute()` and -`client.project(id).automation.execute()` already rejected on a failed run; -they now reject with these two additional classifications, so a caller can tell -"enable the flow and retry" from "the flow definition is broken": - -```ts -try { - await client.automation.execute(flow, { params }); -} catch (err: any) { - err.httpStatus; // 409 | 422 | 400 | 404 - err.code; // 'FLOW_DISABLED' | 'FLOW_NO_START_NODE' | 'FLOW_FAILED' -} -``` - -Callers that branch only on `FLOW_FAILED` keep working for the case they -handle, but will no longer see these two refusals under it — they arrive with -their own codes, which is the point. - -Not affected, and deliberately so: `POST /api/v1/actions/...` with a -`type: 'flow'` action, and metadata-declared `type: 'flow'` endpoints. Both -dispatch the same flow through a different door with its own response -conventions, and whether they should inherit this table is tracked separately. - - diff --git a/.changeset/automation-trigger-status-unification.md b/.changeset/automation-trigger-status-unification.md deleted file mode 100644 index 2eb699edd9..0000000000 --- a/.changeset/automation-trigger-status-unification.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -'@objectstack/service-automation': minor -'@objectstack/runtime': minor -'@objectstack/client': minor ---- - -**BREAKING** — both automation `trigger` routes answer real HTTP status codes for a flow -that ran and failed, instead of HTTP 200 wrapping an inner `{success: false}`. - -This is the second and wider half of the migration the resume route shipped in the same -release (#8684, merged 2026-08-17): one SDK-visible behaviour change, one migration note. -The resume flip touched the screen-flow runner; this one touches the door every app -dispatches flows through. - -Until now a flow driven to a node failure answered: - -``` -HTTP 200 -{"success":true,"data":{"success":false,"error":"Node 'create_opportunity' failed: …"}} -``` - -The run genuinely failed; the transport reported success. A scripted or integration caller -that branches on the HTTP status alone read a failed run as a successful one. This applies -the `/actions` ruling (business failures must not ride HTTP 200 inside a double envelope) -to `POST /api/v1/automation/:name/trigger` and to the legacy -`POST /api/v1/automation/trigger/:name` — the shape `client.automation.trigger()` calls. -Both doors answer through one mapper, so they cannot drift. - -What changes on the wire: - -- **A flow that ran and then failed ⇒ `400` with `error.code: 'FLOW_FAILED'`.** The node - failure stays the human-readable `error.message`. The flow author's own `errorMessage` - travels in `error.details.errorMessage` — one documented location, the same one the - console reads — and the run's per-node accounting in `error.details.summary`. A flow - whose `errorHandling.strategy` is `retry` answers the same way once its attempts are - exhausted. `durationMs` is no longer carried on this response. -- **A flow name the deployment does not hold ⇒ `404`,** answered before anything is - dispatched, through the same registry probe `POST /:name/toggle` and `GET /:name` use. -- **Unchanged:** a successful run still answers 200 with its result, and a run that PAUSED - at a `screen` node still answers 200 with the next screen — a pause is not a failure. -- **Also unchanged, pending a ruling:** a DISABLED flow and one with no start node still - answer 200 with the inner failure. Both are exits that never dispatched anything, and - telling them apart needs a producer-side classification the closed - `AutomationResult.code` union cannot yet express. Tracked on #9378. - -**`@objectstack/service-automation`:** `execute()` now stamps `status: 'failed'` on the -results of runs that dispatched and were rejected — the same lifecycle verdict it already -writes to the run log. Its never-dispatched exits carry no `status`, which is what lets a -transport answer the two classes differently without inspecting the result's internals. - -**`@objectstack/client`:** `client.automation.trigger()`, `client.automation.execute()` and -`client.project(id).automation.execute()` now **reject** on a failed run instead of -resolving with `{ success: false, error }` — the SDK throws on every non-2xx before -unwrapping. Callers that inspected the resolved value must move to a `catch`: - -```ts -try { - await client.automation.execute(flow, { params }); -} catch (err: any) { - err.code; // 'FLOW_FAILED' (400) — the run ran and failed - err.httpStatus; // 400 | 404 - err.message; // the node failure, verbatim - err.details?.errorMessage; // the flow author's own message, when the flow declares one - err.details?.summary; // which node failed -} -``` - -Raw-HTTP callers that treated `2xx` as success and never opened the inner envelope now see -the failure they were already being told about, one level up. - - diff --git a/.changeset/batch-publish-advisories.md b/.changeset/batch-publish-advisories.md deleted file mode 100644 index 9a6e9d9138..0000000000 --- a/.changeset/batch-publish-advisories.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor ---- - -`publishPackageDrafts` (Studio's "publish whole app", `POST /packages/:id/publish-drafts`) now reports the #4463 runtime authoring gate's non-blocking findings on each `published[]` element as an optional `advisories` key — the same element shape and omitted-when-empty discipline as the single-item publish door (#9176). An advisory-free batch's response bytes are unchanged, and `failed[]` elements are unaffected (an `error` finding still aborts the batch). Previously the batch door computed these per-draft findings and discarded them. diff --git a/.changeset/batch-publish-response-declared.md b/.changeset/batch-publish-response-declared.md deleted file mode 100644 index e8c1a8eef6..0000000000 --- a/.changeset/batch-publish-response-declared.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/client": minor -"@objectstack/runtime": patch ---- - -The batch publish response is declared (#9406). `POST /packages/:id/publish-drafts` (Studio's "publish whole app") now has a spec contract behind it: `PublishPackageDraftsResponseSchema` in `@objectstack/spec/api` declares the full wire payload — `success` / `publishedCount` / `failedCount` / `published[]` (each element with its ADR-0008 `version` OCC token and the optional omitted-when-empty `advisories` from #9343) / `failed[]` / `seedApplied` / `materializeApplied` / `commitId`, plus the REST door's own receipts (`unhiddenApps` / `unhideError` / `rebindError`) — the #5745/#7294 "declared = returned" discipline carried to the batch door, with the two pin suites mirroring the single door's pair (spec-side declaration pins plus producer- and route-side conformance gates). `probes` is deliberately opaque in the declaration per the #9406 ruling: the key is declared and carried through verbatim, but its inner `BuildProbeReport` shape is staged until a consumer needs a field of it. `@objectstack/client`'s `packages.publishDrafts` now resolves `PublishPackageDraftsResponse` instead of `any`, and the runtime route ledger names the schema. Additive declaration of an existing wire face — no response bytes change. diff --git a/.changeset/better-auth-family-stable-1-7.md b/.changeset/better-auth-family-stable-1-7.md deleted file mode 100644 index 8df149cebf..0000000000 --- a/.changeset/better-auth-family-stable-1-7.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/plugin-auth": patch -"@objectstack/platform-objects": patch -"@objectstack/client": patch ---- - -deps(auth): the better-auth family moves off the `1.7.0-rc.2` prerelease onto stable `^1.7.1` (#3002) - -`@objectstack/plugin-auth` shipped with **exact pins on a release candidate** — -`better-auth`, `@better-auth/core`, `@better-auth/oauth-provider` and -`@better-auth/sso` all at `1.7.0-rc.2`. That pin was never housekeeping debt: it was -the remediation for **GHSA-p2fr-6hmx-4528** (`@better-auth/oauth-provider`) and -**GHSA-j8v8-g9cx-5qf4** (`@better-auth/scim`, high — account/provider takeover), both -patched only in `>=1.7.0-beta.4`, so there was no stable line to move to. Upstream has -now shipped one: `npm view dist-tags` reports `latest: 1.7.1` for every family -member. The declarations become `^1.7.1`, which is what a downstream -`npx create-objectstack` install now resolves. - -**`@better-auth/scim` deliberately stays at `1.7.0-rc.1`.** Measured against the -published stable tarball rather than assumed: `@better-auth/scim@1.7.1` ships the rc.2 -**rewrite** — no `scimProvider` model, no generate-token endpoint, and six replacement -models (`scimUser`, `scimGroup`, `scimGroupMember`, `scimSubject`, -`scimConnectionBinding`, `scimIdentityTombstone`). Adopting it is a feature migration -(ADR-0071, tracked separately), not a version bump. The hold stays security-clean: rc.1 -is above the advisory's fix floor, `pnpm audit --audit-level=high` is green, and rc.1's -peer ranges accept the stable 1.7.1 core the rest of the family resolves to. - -**Three pieces of upstream drift are absorbed here, and one of them was a live -sign-in outage waiting to happen.** - -`1.7.0-rc.2` renamed the account model's `accountId` field to `providerAccountId`; -**stable 1.7.0/1.7.1 renamed it back to `accountId`**, keeping the new required -`issuer`. Carrying the rc.2 spelling into the stable line left the field unmapped, so -better-auth's adapter asked for a column named `accountId` and **every sign-up answered -500** — `Unknown field 'accountId' on object 'sys_account'`. The `account_id` column -itself never changed and no data moves; only the camelCase key does. The same rename -reaches `@objectstack/client`: `auth.accounts.list()` (better-auth's `/list-accounts`) -returns `accountId`, and its declared response type said `providerAccountId`. If you -read that field off the client's typed response, rename it. - -`@better-auth/oauth-provider` 1.7.1's client model writes three fields the platform -object did not answer for. `applicationType` is the OIDC spelling of what rc.2 called -`type`, so it maps onto the **existing** `type` column and no data moves; -`clientDiscoveryId` and `clientCredentialsScopes` are genuinely new and are now -declared on `sys_oauth_application` as `client_discovery_id` and -`client_credentials_scopes`. Without them, dynamic client registration -(`POST /oauth2/register`) fails at the driver. - -Two endpoints are newly mounted by the auth catch-all and are now ledgered: -`POST /oauth2/end-session` and `POST /oauth2/end-session/confirm` — the POST form of -OIDC RP-initiated logout, whose `GET` counterpart was already published. - -**Nothing here needs an action on upgrade.** The new columns are additive and optional, -and the field rename is internal to how the plugin talks to better-auth — with the one -exception of the `@objectstack/client` response type named above. diff --git a/.changeset/boot-widening-bounded-lock-wait.md b/.changeset/boot-widening-bounded-lock-wait.md deleted file mode 100644 index f8a876f721..0000000000 --- a/.changeset/boot-widening-bounded-lock-wait.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): boot schema-sync's MySQL widening ALTER bounds its metadata-lock wait too — a blocked boot warns and carries on instead of hanging for a year (#9542) - -#9354 bounded `lock_wait_timeout` to 120s on the widening `ALTER TABLE … MODIFY -COLUMN` and made a blocked `os migrate apply` refuse loudly — but only while -`flushDeferredSchemaDdl` was running. The same two widenings -(`migrateMysqlDatetimeColumns` / `migrateMysqlTimeColumns`, #3942 / #3994) are -reached from **boot schema-sync** through the same `initObjects` lines, and on -that path the `runWideningAlters` seam returned early: the ALTER ran through the -pool inheriting MySQL's own default `lock_wait_timeout` of **31,536,000 seconds -— one year**. - -So a single other session holding a metadata lock on the table parked boot at -schema-sync for that long, printing nothing — indistinguishable from a crash. -The widening's own `logger.warn` could not help, because it lives in a `catch` -and an ALTER that never returns is never caught. - -The bound is now armed **unconditionally** in that seam. What stays gated on the -flush is the **refusal**, and only it: boot still swallows. Correctness never -depends on the widening having run and a migration must never take boot down, so -throwing there would trade a silent hang for a failed boot — a different answer, -not the same one. - -**What changes for a deployment.** On MySQL, a boot whose widening ALTER is -blocked on a metadata lock now waits at most 120s, then logs -`[sql-driver] could not widen MySQL datetime columns on …` (or its `TIME(3)` -twin) naming the table, with the server's own `Lock wait timeout exceeded` as -the `error` field — and boot carries on. The widening is idempotent, so the -first boot after the blocker is gone completes it. Nothing changes on any other -dialect, on an ALTER that is not blocked, or for `os migrate apply`, which keeps -#9354's `DATABASE_ERROR` / 500 refusal. - -120s is #9354's number, kept for boot deliberately rather than lengthened: the -reasoning behind it is about how long a legitimate metadata-lock holder can -plausibly hold the lock, which is a property of the lock and not of who is -waiting on it. Boot's difference from the flush is what happens when the bound -fires, never how long it waits. No retry logic and no configurability — the -2026-08-17 ruling's minimality, unchanged. 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/cascade-probe-multivalue-lookup-filter.md b/.changeset/cascade-probe-multivalue-lookup-filter.md deleted file mode 100644 index 95ea494ee0..0000000000 --- a/.changeset/cascade-probe-multivalue-lookup-filter.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(engine): `cascadeDeleteRelations` probes a `multiple: true` reference field with a spelling its storage can answer, so REST DELETE stops returning 400 for every object such a field points at (#9362) - -Any object pointed at by any registered `multiple: true` `lookup` / -`master_detail` field had its data-plane delete refused outright: - -``` -POST /api/v1/data/showcase_account {"name":"anything","status":"active"} -> 201 -DELETE /api/v1/data/showcase_account/ -> 400 INVALID_FILTER -``` - -On the stock showcase that is `showcase_account`, because -`showcase_field_zoo.f_lookups` is `Field.lookup('showcase_account', { multiple: true })`. -The fault is **schema-driven, not data-driven** — the dependents probe runs once -per DECLARED relation, so emptying the referring table changes nothing. - -**Mechanism.** The probe built a bare-equality filter for every reference field -aimed at the object being deleted, including the multi-value ones. A -`multiple: true` field stores an array, which every SQL backend here puts in a -JSON TEXT column, so bare equality compares the whole serialization (`["a","b"]`) -against one id and can never hold. `driver-sql` refuses that spelling -(`INVALID_FILTER` / 400) rather than compiling a silently wrong answer. - -**Neither of the two correct behaviours around it was touched.** The driver's -refusal stays — it is right, and loosening it would restore the fail-both-ways -comparison it exists to stop. The discriminate-or-propagate `catch` that -surfaces a probe failure instead of inventing "no dependents" stays too: the -probe's filter spelling was never correct, and that tightening only turned a -silent wrong answer into a loud one. - -**The fix is at the probe's construction site, and nowhere else.** A multi-value -field is asked with `$contains` — the membership spelling the refusal itself -prescribes, and the one every driver here answers (`driver-sql` and the two -drivers extending it lower it to `LIKE '%v%'` over the serialization, -`driver-mongodb` and `driver-memory` to a `$regex` that matches per element). No -filter or predicate surface is widened: `$contains` was already declared, and the -single-valued probe is byte-identical to what it was. - -`$contains` is a SUBSTRING test, so on every one of those backends the pushdown -answers a **superset** — with ids `acc_1` and `acc_10`, a row holding `acc_10` -matches a probe for `acc_1`. The rows are therefore narrowed exactly afterwards, -element-wise, the same reading the dangling-reference audit already applies to a -stored reference. Without that half the fix would make `cascade` delete and -`set_null` clear rows that never referenced the record — worse than the 400. An -id needing JSON escaping is asked for in both stored spellings, so the guard -cannot fail open on it either. - -Both directions are pinned, against a driver double that reproduces the JSON -column refusal and against a real `SqlDriver` on better-sqlite3 driven through -the real data-plane delete: the delete succeeds and the row is gone, a live -dependent through the array still refuses with `DELETE_RESTRICTED` / 409, and an -id that is a prefix of another neither inherits its dependents nor loses its own. - -## Shipping with it: a TEMPORARY refusal on `set_null` over a multi-value reference - -Maintainer-ruled to land in the same change, and **explicitly a holding position -rather than a semantic**: while a `multiple: true` reference field would take the -`set_null` limb, the delete is now refused (`DELETE_RESTRICTED` / 409) instead of -executed. - -Repairing the probe is what would make that limb run for the first time in this -codebase — before #8895 the probe swallowed its own failure and skipped the -relation, after #8895 it raised `INVALID_FILTER` and aborted the delete — and the -limb writes `null` over the WHOLE array, discarding every other member. Measured -on the real stack: a row holding `["acc_a","acc_b"]` re-reads as `null` once -`acc_a` is deleted. - -The right semantics is "remove just the deleted member", but the residual shape -when the array empties (`[]` or `null`) is observable on the read path and to a -required multi-value validator, and nothing in `FieldSchema` pins it. That -question is tracked in objectstack#9438; refusing loudly until it is answered -decides nothing and reverts in one `if`, while writing would decide it by -accident and cannot be undone for the rows it touched. - -**Scope of the refusal, and what it deliberately leaves alone.** It is the -required-FK escalation directly above it, applied to an adjacent case: the same -`behavior` reassignment, reading the same `behavior === 'set_null'`. Because -`fdef.deleteBehavior || 'set_null'` collapses an absent declaration and an -explicitly authored `set_null` into one value, both are covered — the same way -both are already covered by the required-FK escalation, and without adding a -distinction the existing shape does not make. An explicit `cascade` or `restrict` -is untouched, a single-valued `set_null` still clears its foreign key, and a -relation with no dependent rows still deletes: only the disposition changes, so -the P0 above genuinely closes for every other path. - -**No new wire code**, per the rule `operation-message.ts` already states for this -envelope — one `DELETE_RESTRICTED` with more than one sentence, splitting the -sentence and never the code. The reason is developer-facing, so it rides -`developerMessage`, which names the refusal as temporary and cites the tracking -issue literally so removing this is one grep. The business message a user reads is -unchanged, because their action is unchanged: clear or reassign the referencing -records. diff --git a/.changeset/cascade-registry-read-propagates.md b/.changeset/cascade-registry-read-propagates.md deleted file mode 100644 index ddb4706d48..0000000000 --- a/.changeset/cascade-registry-read-propagates.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): the delete-cascade path's two registry reads propagate instead of answering "no relations" (#9002) - -`ObjectQL.delete()`'s by-id branch reads `registry.getAllObjects()` twice, and -both reads sat behind a swallow that invented an answer for a read that never -happened: - -- `planCascadeAtomicity()` — `catch { return 'none' }`. `'none'` is the verdict - that asserts *nothing references this object*, so #7413's cascade atomicity - was silently switched off on a registry nobody could read. -- `cascadeDeleteRelations()`, its first statement — `catch { return }`. That - skips the cascade entirely: no `restrict` refusal, no `set_null`, no - `cascade`, nothing logged, and the caller told the delete succeeded. - -This is the #8895 shape one layer up, with a strictly larger blast radius: -#8895's `catch` invented "no dependents" for one relation whose probe could not -run; this one invented "no relations" for every relation at once, before the -per-relation probe was ever reached. - -#8895 ruled the family *discriminate or propagate*. Discrimination needs a -benign failure class — there, an unprovisioned child table, which genuinely -cannot hold a referencing row. Here there is none: an unreadable registry is -never truthfully "no relations". So both `catch`es are removed and the read's -own failure reaches the caller, envelope intact — no new error code, no new -response field, and the second seam is decided in the same direction as the -first because its own argument rested on the first one firing. - -**No shipped behaviour changes.** `SchemaRegistry.getAllObjects()` is a walk -over in-memory `Map`s (`resolveObject()` returns `undefined` on every failure -branch it models) with no I/O and no `throw` on the measured path, so nothing in -a running deployment can reach either seam today. This is a structural close of -a fail-open shape, pinned by tests, so that the day the registry read grows a -throwing path it fails loudly instead of disabling every referential guard at -once. diff --git a/.changeset/cascade-required-multivalue-per-row.md b/.changeset/cascade-required-multivalue-per-row.md deleted file mode 100644 index cd792386ff..0000000000 --- a/.changeset/cascade-required-multivalue-per-row.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(engine): the required-FK escalation on a `multiple: true` lookup is judged per ROW — a parent delete is refused only over the rows member removal would EMPTY (#9688) - -`cascadeDeleteRelations` escalated `set_null` → `restrict` on `fdef.required === true` -before the multi-value branch and before the dependents probe had run, so a delete was -refused for every row that referenced the record, whatever else that row's set held. -Measured with a real engine + stub driver: a child holding `accounts: [acct_a, acct_b]` -on a `required: true, multiple: true` lookup refused `DELETE acct_a` with -`DELETE_RESTRICTED` / 409 / `dependentCount: 1`, leaving the set untouched. - -**The escalation's own rationale is what bounds it.** It exists because clearing a -required foreign key issues an UPDATE the child's validator rejects with a misleading -`" is required"` 400. On a `multiple: true` field the `set_null` limb does not -clear the slot — since #9438 it removes the deleted MEMBER and writes the remainder — so -that failure is only reachable for a row the removal would EMPTY. Removing `acct_a` -above writes `[acct_b]`, a non-empty required set no validator objects to; the delete was -refused citing a failure that could not have happened. - -**Now decided per row, after the dependents probe and the exact multi-value narrowing:** - -- remainder non-empty → the member is removed and the delete proceeds (#9438 semantics, - which the #9447 ruling accepts); -- remainder empty (the deleted member was the last) → `DELETE_RESTRICTED` stands, because - `[]` violates `required` on a multi-value field under #9447 and is rejected by the - record validator since #9476; -- when both kinds of row reference the record the whole delete is refused, and - `dependentCount` now counts **only the rows that would be emptied** — previously it - counted every referencing row, naming rows the delete no longer objects to. - -The judgement and the write share one function (`remainderAfterMemberRemoval`), so the -predicate that clears the write can never predict a shape the write would not produce. - -**Unchanged:** single-valued `set_null` on a required lookup still escalates (clearing a -scalar FK always writes `null`), an authored `deleteBehavior: 'restrict'` still refuses -regardless of emptiness, `cascade` is untouched, and a non-required multi-value lookup -keeps removing the member as before. - -The #9625 fixture pinning the previous, broader refusal is updated deliberately rather -than repaired — that is what it was pinned for — and the last-member refusal is pinned -beside it, since that pin is what makes the narrowing safe. Also pinned: the defaulted -`set_null` spelling reaches the same per-row judgement as the explicit one, an authored -`restrict` is not narrowed, and `dependentCount` reports the refused rows only. diff --git a/.changeset/cascade-set-null-multivalue-member-removal.md b/.changeset/cascade-set-null-multivalue-member-removal.md deleted file mode 100644 index 12a4e3e086..0000000000 --- a/.changeset/cascade-set-null-multivalue-member-removal.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(engine): `deleteBehavior: 'set_null'` on a `multiple: true` reference field removes the deleted MEMBER from the stored array instead of nulling the whole slot, and the temporary 409 hold shipped with #9437 is removed (#9438) - -On a set-valued foreign key, "set null" now means what it has to mean: the -reference to the deleted record is filtered out of the stored array and the -remaining members are written back untouched. Before the interim hold, this -limb wrote `null` over the WHOLE array — a row holding `["acc_a","acc_b"]` -re-read as `null` after `acc_a` was deleted, silently dropping the live -reference to `acc_b`. - -**The residual shape is the ruled one, consumed rather than decided here.** -When the last member is removed, the field is written as **`[]`, never -`null`** — the representation `FieldSchema` pins as verbatim contract -(`packages/spec/src/data/field.zod.ts`, the `multiple` and `required` doc -blocks; #9447, maintainer ruling 2026-08-18, binding for every writer). The -open question that kept this limb held back was exactly that shape; it is -now answered at the spec, and this write consumes the answer. - -**The temporary holding position is removed in the same stroke — it was -built to be removed.** #9437 shipped an explicit interim: any delete that -would take the `set_null` limb on a multi-value reference was refused -`DELETE_RESTRICTED` / 409, with a `developerMessage` naming the refusal -TEMPORARY and citing the tracking issue literally. Those deletes now -succeed and remove the member. The refusal envelope for a genuinely -configured `restrict` is unchanged, and the interim's extra sentence is gone -with the interim. - -Removal compares whole members (`String(v) !== String(id)`), the same -reading the dependents narrowing already applies — the probe's `$contains` -pushdown is a substring superset, so an id that is a prefix of another -neither loses its own member nor takes its neighbor's. Every other -disposition is untouched: `cascade` still deletes dependents, a -single-valued `set_null` still clears its foreign key to `null`, an -explicit `restrict` still refuses with its own sentence, and the required-FK -escalation stays exactly as it was. - -Pinned against the driver double that models the JSON TEXT column and -against a real `SqlDriver` on better-sqlite3 through the real data-plane -delete: member removal with a surviving sibling that still resolves, the -emptying case asserted literally as `[]` and not `null` on a re-read of the -database, and the controls beside them. - -Note: `required` on a multi-value lookup now *documents* non-empty-array -semantics (same ruling), but the validator does not yet enforce it — that -enforcement gap is tracked separately in #9476 and is not changed here. diff --git a/.changeset/cbp-master-detail-required-forced.md b/.changeset/cbp-master-detail-required-forced.md deleted file mode 100644 index bd258cd783..0000000000 --- a/.changeset/cbp-master-detail-required-forced.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): the builder forces `required: true` on a `master_detail` reference under `controlled_by_parent` (#9138 — #8772 maintainer ruling, Direction 2) - -**BREAKING** accept-face narrowing on the authoring builder, landing after the -v17.0.0 cut (the lockstep launch-window convention ships it as `minor`; the -prescription is registered under protocol major 18, where `os migrate meta` -users will look). - -A `controlled_by_parent` object derives ALL of its record access from the -master its `master_detail` reference names (ADR-0055). A master reference that -is not `required` arms the worst measured failure shape: an insert may omit -the master FK, the row lands with a null FK that the derived read filter -(`masterFK IN (accessible master ids)`) can never match — unreadable by -everyone — and every later by-id write answers `422 MISSING_REQUIRED_FIELD`. -#8772 measured that only the security gate closed that shape while the -declaration surface accepted it. - -`ObjectSchema.create()` now makes the unsafe shape impossible to newly -declare: - -- an **omitted** `required` on a `master_detail` reference under - `sharingModel: 'controlled_by_parent'` is **forced to `true`** in the - emitted object; -- an **explicit `required: false`** there is **refused** with a located error - naming the object, the field, the consequence and the fix — an explicitly - authored contradiction is not silently rewritten (ADR-0032). - -## FROM → TO - -```ts -// before — parsed green; the null-FK trap stayed armed behind the security gate -export const InvoiceLine = ObjectSchema.create({ - name: 'invoice_line', - sharingModel: 'controlled_by_parent', - fields: { invoice: { type: 'master_detail', reference: 'invoice', required: false } }, -}); - -// after — refused at build with the prescription; omitting `required` is fine -// (the builder emits `required: true` by construction) -export const InvoiceLine = ObjectSchema.create({ - name: 'invoice_line', - sharingModel: 'controlled_by_parent', - fields: { invoice: { type: 'master_detail', reference: 'invoice', required: true } }, -}); -``` - -**What stays accepted:** everything outside that one shape. Raw -`ObjectSchema.parse()` / `.safeParse()` — the path metadata at rest rehydrates -through — still accept the old shape unrewritten, so existing installs keep -loading; the security gate's derived enforcement stays; the lint rule -`relationship/master-detail-required` stays `warning` until its own v18 -promotion (#9139). The 2026-08-15 survey measured the shipped first-party -surface at exactly 3 `controlled_by_parent` objects, all already -`required: true` — zero first-party migration. - - 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/cli-migrate-duplicates-inventory.md b/.changeset/cli-migrate-duplicates-inventory.md deleted file mode 100644 index 0ba8c21c11..0000000000 --- a/.changeset/cli-migrate-duplicates-inventory.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli): `os migrate duplicates` — an operator-facing inventory of the business identifiers the tenancy split already minted twice (#8928) - -Two producers of untenanted rows have been closed (#8686's seed loader plus its -one-shot backfill, and #8844's runtime system-context write). Neither touches -the **damage already done**, and both rulings say the same thing about it: a -business identifier that has already been handed out — on an invoice, in a -notification, in another system's idempotence key — is not the platform's to -rewrite. What an operator needs instead is to know **which ones they are**. - -```bash -os migrate duplicates # the whole report, JSON on stdout -os migrate duplicates > duplicates-2026-08-17.json -os migrate duplicates --object crm_case # narrowed (and the report says so) -``` - -**Run it BEFORE the #8686 backfill is applied.** The evidence is perishable: -`organization_id = NULL` is the marker that says "this row came from the -untenanted side", and it is exactly what that repair overwrites. The repair also -merges and deletes the `__global__` counter, which is the report's only -forward-looking line — an install that repairs before reporting can never -produce it again. The command itself applies nothing: it boots read-only (no -DDL, no seed, no database file brought into existence) and issues SELECTs only. - -What the report contains, per the 2026-08-16 maintainer ruling on all five of -the card's decision points: - -- **one row per duplicated value, with its holders** — id, organization, - partition and creation timestamp per row, so the operator can decide case by - case rather than per value. JSON on stdout, no persistence and no new schema: - the operator archives it; -- **the narrow definition of duplicate** — a value held by rows in more than one - of the partitions `COALESCE(organization_id, '__global__')` separates - (ADR-0120 D3). A value repeated *inside* one partition is refused by the - partitioned unique index and is not reported; -- **the live condition too** — an object still running a `__global__` counter - beside an organization-scoped one is about to mint more duplicates; -- **a data-side probe** — `GROUP BY HAVING COUNT(*) > 1` over the - object's own table, never an enumeration of `_objectstack_sequences`, so a - duplicate whose counter was since merged is still found. The counter table is - read for the live condition alone, because that fact lives nowhere else. - -Scope is every registered object that is organization-scoped, and on it every -`autonumber` or `unique` field. `sys_` / `cloud_` / `ai_` objects are **not** -filtered out — that filter is correct for a repair (platform seeds stay global -by design) and wrong for a report, which must not silently omit a real -duplicate. Anything that could not be probed is listed in `skipped` with the -driver's own message, so a target the command could not read never reads as a -target with no findings; a driver with no raw-SQL seam refuses loudly and exits -non-zero rather than reporting zero duplicates. - -⛔ Reporting is all it does. Renumbering, deduplicating or otherwise rewriting an -already-minted identifier stays out of scope per both rulings. 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/console-82a94170c405.md b/.changeset/console-82a94170c405.md deleted file mode 100644 index 21c29d1aeb..0000000000 --- a/.changeset/console-82a94170c405.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `82a94170c405`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 121 releasing of 166 changesets added across 191 non-merge commits; omitted: 45 release-nothing changesets, 30 commits carrying no changeset (they ship no package code). - -- **minor** — `ComponentMeta.labelling` grows a third value: `'control' | 'group' | 'display'` (objectui#4857, ruled jointly with objectui#4871 as the single repo-wide vocabulary for "how does… (objectui `167ec42e7`) -- **minor** — Publish the five `@objectstack/spec` 17.0.0 keys the renderers already honoured, so authors can discover them (objectui `98eab3681`) -- **minor** — `element:text.content` and `element:button.label` declare the inline translation map they already accept (objectui `8b9dc627f`) -- **minor** — `ComponentInput.type` can declare a UNION, so a block stops warning about legal writes its own description recommends (objectui `279fb139d`) -- **minor** — `AuthInvitation.status` is the closed four-member union it always documented, enforced at the auth client's wire boundary. (objectui `1b21b1aaa`) -- **minor** — **Breaking (shipped as `minor`, following the `app-shell` component-key deregistration):** `app-schema-renderer`'s `mobileNavMode` is now declared as the vocabulary the renderer i… (objectui `45f7dcca4`) -- **minor** — layout: `flex` and `container` now honour a declared scale value of `0` (objectui `93fe362fc`) -- **minor** — **BREAKING** — The fullscreen long-text dialog announces the field's validation state and carries the field's name (objectui `911ceaa05`) -- **minor** — **Breaking (shipped as `minor`, following the `page-header` `description` retirement):** `app-shell` is no longer a component key. `registerLayout()` registered `AppShell` under t… (objectui `29167d540`) -- **minor** — **BREAKING** — A dashboard header `modal` action's `target` names a PAGE, only — DashboardView's second copy of the prefix convention retires (objectui `c25222758`) -- **minor** — **BREAKING** — A `type: 'modal'` action's string `target` names a PAGE, only — the object fallback retires (objectui `171601c02`) -- **minor** — **Breaking (shipped as `minor`, see below):** `@object-ui/layout`'s `` retires the legacy `description` prop. `subtitle` is now the only spelling for the secondary lin… (objectui `f923b7cfa`) -- **minor** — **BREAKING** — Settle the two declared-but-unread keys on `AccordionItem`: retire `icon`, wire `disabled` (objectui#4652). (objectui `58b8346be`) -- **minor** — **BREAKING** — data-objectstack: retire the four remaining `v3.0.0 Deep Integration` modules — `IntegrationManager`, `SecurityManager`, the studio canvas helpers (`createDefaultCanvasConfig` / `… (objectui `a8411adf1`) -- **minor** — A clicked cartesian mark names its own series, and the drill title reads its label (objectui `e05db8826`) -- **minor** — Remove `VectorFieldMetadata.indexed` and `VectorFieldMetadata.distance_metric` — both declared keys the ObjectStack spec rejects (objectui `9695da72b`) -- **minor** — Remove `BaseFieldMetadata.indexed` — the ObjectStack spec has no field-level index flag (objectui `97abb24a9`) -- **minor** — Draw a null second-dimension group instead of carrying its measure invisibly (objectui `af025ee70`) -- **minor** — Give a chart bucket an identity distinct from its display label (objectui `9ce096fb0`) -- **minor** — Retire the field designer's `Indexed` toggle — the ObjectStack spec has no field-level index flag (objectui `deb157ad0`) -- **minor** — Action param `visible`: one dialect answer, and a fault that is fail-open and LOUD (objectui `e1d42519b`) -- **minor** — Project every declared `recordIdField`, and refuse an action that names no record (objectui `b1119ece4`) -- **minor** — Remove the retired `striped` / `bordered` / `virtualScroll` list-view surface (objectui `f1d4748c6`) -- **minor** — **BREAKING** — Settle the two declared-but-unread keys on `ToggleGroupItem`: retire `icon`, wire `disabled` (objectui#4632). (objectui `99bd01542`) -- **patch** — docs(plugin-report): rewrite the README export snippets against the real export signatures (objectui `82a94170c`) -- **patch** — Docs only: `packages/plugin-gantt/README.md` no longer teaches two identifiers the package does not export, nor a task shape it does not produce (objectui#5012). Each README impor… (objectui `bb3fab62c`) -- **patch** — The `span` renderer renders its content again — it reads `children`, the key its own type declares and its producers emit. (objectui `839665643`) -- **patch** — A form's declared redirect delay no longer outlives the form that armed it. (objectui `f68018d56`) -- **patch** — `CloudConnectionPanel` prefers `error.message` over `error.code` on a bind-poll failure, matching the precedence the same file already applies everywhere else. (objectui `ee4f796d2`) -- **patch** — Docs only: `packages/plugin-report/README.md` no longer teaches three exports the package does not have (objectui#5016). Each was judged individually against the entry module's ex… (objectui `f331f5a8b`) -- **patch** — `plugin-calendar`'s README no longer documents imports the package does not export. (objectui `e7c5a80a8`) -- **patch** — metadata-admin: SchemaForm emits the naming channel each widget DECLARES, and the colour widget splits into two registrations (objectui `dc9d651a2`) -- **patch** — `objectui serve` and `objectui build` now locate the project the way `dev` does, instead of looking only in the current directory. (objectui `bbfbc54cb`) -- **patch** — `ObjectForm` and `WizardForm` now consume a declared `submitBehavior: { kind: 'redirect' }` the way objectstack#7496 ruled it (objectui#4989): the destination is a **relative** pa… (objectui `ae804eca2`) -- **patch** — fix(components): FilterBuilder 的值不再落在列的选项集之外还看不见 (objectui#4874) (objectui `384f30dd7`) -- **patch** — The `span` deprecation notice is now reported once per page load, and only to the authoring surface it applies to. (objectui `1c9c34292`) -- **patch** — The metadata Audit panel's lock column now shows Chinese for every lock state it can print. (objectui `e71c854ce`) -- **patch** — Console list filters: a `between` range is submitted only when both bounds are filled, and six operator labels stop rendering as raw i18n keys. (objectui `a1609a603`) -- **patch** — The Public Forms dialog now refuses an out-of-contract `submitBehavior.url` at the moment it is authored, with the contract's own prescription shown next to the field (objectui#49… (objectui `ab9c9709d`) -- **patch** — The Studio's overlay-layer badge stops printing the producer's raw scope value (objectui `3d053bb24`) -- **patch** — A map view now fits its camera to the records it queried, so a view with data never first-paints an empty viewport. (objectui `25819c42c`) -- **patch** — `FormPage`'s post-submit `redirect` behaviour now consumes the destination the way objectstack#7496 ruled it (objectui#4190): as a **relative in-app path**, navigated to with the… (objectui `a34c0b299`) -- **patch** — `MetadataClient.layered()` now reads the three-layer view from its declared path, `GET /meta/:type/:name/layers`, instead of flagging the ordinary item read. (objectui `cf4f8a6e4`) -- **patch** — Grid and related-list column headers no longer offer a sort on a `formula` column. (objectui `c1ef923cf`) -- **patch** — The routed temp app's generated manifest now asks for the same `lucide-react` range this repo installs. (objectui `195b9e4ab`) -- **patch** — A scaffolded plugin's generated manifest now asks for the same `@testing-library/jest-dom` range this repo installs. (objectui `195b9e4ab`) -- **patch** — Retired field-type spellings can no longer reach an inline editor by delegation — the grid's inline cell editor stops offering a working person picker for `owner`. (objectui `ac2f3324c`) -- **patch** — The marketplace plugin trust tier is typed by the spec's own enum, so the install panel can no longer render a raw wire value as a trust label. (objectui `1b21b1aaa`) -- **patch** — A write-warning toast now words each strip reason on its own instead of calling everything that is not `readonly_when` "read-only". (objectui `21e45858f`) -- **patch** — fix(fields): deliver the host's a11y channels to `slider` and name `signature` (objectui `dad51e5dc`) -- **patch** — The retired `owner` field-type spelling stops being blessed by the published contract, and inline edit refuses it the way the record form already does. (objectui `598c89a17`) -- **patch** — Inside a pnpm workspace, `objectui dev` / `serve` / `build` now resolve every platform package from workspace source (objectui#3890). (objectui `4102bfcf8`) -- **patch** — fix(fields): retire the `owner` field-type alias with a loud tombstone (objectui `e7747f19a`) -- **patch** — `div` 的废弃提示按 provenance 收窄:只对 **JSON 作者面**的节点报,不再对 `kind:'html'` tier 自己解析出的节点开火。 (objectui `40d3a3318`) -- **patch** — **BREAKING** — fix(fields): grid columns are keyed by the declared `name`, so spec-compliant grid metadata renders populated cells (objectui `800f455f7`) -- **patch** — fix(plugin-report): forward the chart chrome and series presentation `ReportChartSchema` declares (objectui#4877) (objectui `5ffcc1432`) -- **patch** — fix(plugin-report): route a report's embedded chart through `buildChartSeries` so a NULL category is bucketed (objectui#4878) (objectui `5ffcc1432`) -- **patch** — The metadata editor's reset/delete button now renders the verb it executes, decided by the server's own verdict. (objectui `0a3ab5edf`) -- **patch** — Publish the authoring surfaces of the four GA `object-*` blocks (objectui `375efb402`) -- **patch** — A flow-run failure that arrives as `400 FLOW_FAILED` — and any `404` on the flow route — is now classified as terminal rather than retryable. (objectui `833c90047`) -- **patch** — `FilterBuilder` shows the falsy values a row actually holds — a boolean `false` and a number `0` are values, not empty boxes. (objectui `53f23bc33`) -- **patch** — `@object-ui/types` stops publishing its `src/` tree (objectui `c9dc811d0`) -- **patch** — metadata-admin stops declaring an object "provided by an installed package" when it lives in a writable package (objectui `3b0912bae`) -- **patch** — The Studio Interfaces rail opens `action` nav entries instead of greying them out. (objectui `718ca9d45`) -- **patch** — 报表内嵌图表的度量显示名按三级回落解析,不再直接印原始 `name` (objectui `6bb39c412`) -- **patch** — The designer's colour swatch rows now announce WHAT they colour, and the Dashboard widget inspector's "Color Variant" label no longer points at nothing. (objectui `14f8b7ad5`) -- **patch** — Combo charts drill from their marks (objectui `f95434b26`) -- **patch** — `FilterBuilder` settles a row's **value** when its field changes, instead of leaving a value the new column's input cannot show. (objectui `be6081548`) -- **patch** — `object-gantt` / `object-map` / `object-calendar` no longer drop a sort entry that omits `order`. (objectui `5edc0c522`) -- **patch** — `@object-ui/fields` stops publishing its `src/` tree (objectui `65e88e6c2`) -- **patch** — A readonly field's replacement display is now named by the field's label and described by its help text. (objectui `7458a418b`) -- **patch** — `@object-ui/data-objectstack` stops publishing its `src/` tree (objectui `1ef236e18`) -- **patch** — Four packages stop publishing tooling material in their `dist/` (objectui `ad07b65ac`) -- **patch** — `mobile.fullscreenLongText` now reaches fields the spec spells `richtext` (objectui#4831). (objectui `3b0370408`) -- **patch** — Permission reads no longer throw on a config that omits the required `roles` member. `ObjectPermissionConfig.roles` is declared required, but a config arriving from plain JS or fr… (objectui `61556dce4`) -- **patch** — Editable `markdown` / `html` / `richtext` fields now carry the host's `id` and `aria-describedby` on the editor. (objectui `a777058f6`) -- **patch** — A `user` field in a form now receives `dataSource` / `dependentValues` / `dependsOnLabels`, like every other reference field. (objectui `ac600e5d2`) -- **patch** — SpecBridge's form-view bridge stops reading `defaultSort` and `aria`, two keys spec 17 retired on the FormView carrier (`retiredKey()` tombstones — authoring either is a parse err… (objectui `d374cafa2`) -- **patch** — AppShell: drop the unused `Sidebar` import. `AppShell` renders the node the caller passes in the `sidebar` prop and never constructs a `Sidebar` itself, so the import was dead (tr… (objectui `d44279598`) -- **patch** — Studio 页面设计器不再为 canonical `page:header` 提供 `icon` 编辑框(objectui#3829) (objectui `183d09b78`) -- **patch** — The allow-list of option widgets that are fed the live record is now one exported constant, `CASCADE_OPTION_WIDGET_TYPES`, instead of three private copies. (objectui `bbe8b86e8`) -- **patch** — `SidebarNav`'s README example teaches the shape the component actually reads. (objectui `8a9deceea`) -- **patch** — A readonly group-labelled field is now DESCRIBED by its own help text, not just named by its label. (objectui `0bffb1848`) -- **patch** — The "modal target names no page" diagnostic is one message again, on all three surfaces (objectui `5574ed67d`) -- **patch** — `FilterBuilder` settles a row's operator when its **field** changes, instead of leaving an operator the new field's dropdown does not list. (objectui `c4533dc8f`) -- **patch** — Fix: a `drawer` form with no `sections` now renders the object's declared `fieldGroups` as sections, matching `ObjectForm` and `ModalForm`. (objectui `9b20dea36`) -- **patch** — The form's cascade clear now recognises object-form fields, so a narrowed option list no longer submits a stale value. (objectui `78c0f9ae8`) -- **patch** — `DashboardWidgetSchema`: stop re-typing the retired `responsive` key as `any` (objectui `7f96b10e8`) -- **patch** — Fix: a `drawer` form with no `sections` now honours the object's field-level conditional rules (`visibleWhen` / `readonlyWhen` / `requiredWhen`) and field `group`. (objectui `469b60493`) -- **patch** — The console's embedded index editor no longer offers controls for keys the spec removed. (objectui `9a3d04e4e`) -- **patch** — A bulk action dialog's per-option `visibleWhen` predicates now read the dialog's own in-progress param values. (objectui `9aecabe18`) -- **patch** — `FilterBuilder` gives the set and range operators an input that matches the value shape the spec accepts, and stops minting the shape it refuses. (objectui `2b5026136`) -- **patch** — A create form no longer deadlocks on a `requiredWhen` field that also declares a runtime `defaultValue`. (objectui `d971e51c0`) -- **patch** — An action dialog's per-option `visibleWhen` predicates now read the dialog's own in-progress param values. (objectui `2646ccb72`) -- **patch** — The config panel footer translates: `ConfigPanelRenderer`'s Save / Discard labels come from the locale pack. (objectui `2e82ab2ae`) -- **patch** — The dashboard config sidebar translates: `WidgetConfigPanel` and `DashboardConfigPanel` are wired through `t()`. (objectui `ef0d1502f`) -- **patch** — Fix a list filter that silently applied nothing when the first thing you picked was **Is null** or **Is not null**. (objectui `138ab04d5`) -- **patch** — Delete two dead i18n namespaces — `configPanel.*` (16 keys) and `renderer.*` (13 keys) — from all ten locale packs. (objectui `f34226ea9`) -- **patch** — The list filter builder no longer offers `Is set` / `Is not set`, which its query dialects cannot express. (objectui `616a2a547`) -- **patch** — Delete two dead i18n namespaces — `workflow.*` (58 keys) and `publicForm.demo.*` (36 keys) — from all ten locale packs. (objectui `564b60523`) -- …and 21 more releasing changesets in this range (list capped at 100; see the objectui range below). - -⚠️ 10 of these carry a breaking change: 10 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. - -**In this console build, declared nowhere** — objectui merged 30 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. - -- _(no changeset)_ chore(deps-dev): bump the dev-dependencies group across 1 directory with 11 updates (#4948) (objectui `590dd6356`) -- _(no changeset)_ chore(deps): bump @objectstack/lint from 17.0.0-rc.6 to 17.0.0 (#4954) (objectui `e609aacc5`) -- _(no changeset)_ chore(deps): bump lucide-react from 1.29.0 to 1.31.0 (#4959) (objectui `c1454a2d9`) -- _(no changeset)_ chore(deps): bump @objectstack/client from 17.0.0-rc.6 to 17.0.0 (#4960) (objectui `ed3e55c34`) -- _(no changeset)_ chore(deps): bump @objectstack/formula from 17.0.0-rc.6 to 17.0.0 (#4955) (objectui `080fa918f`) -- _(no changeset)_ chore(deps): bump @objectstack/spec from 17.0.0-rc.6 to 17.0.0 (#4953) (objectui `7484f7f0a`) -- _(no changeset)_ chore(deps): bump @sentry/react from 10.69.0 to 10.70.0 (#4957) (objectui `7e3342ea1`) -- _(no changeset)_ chore(deps): bump maplibre-gl from 6.2.0 to 6.3.0 (#4956) (objectui `4e35868f1`) -- _(no changeset)_ chore(deps-dev): bump rollup-plugin-visualizer from 7.0.1 to 7.1.1 (#4951) (objectui `4e8579f01`) -- _(no changeset)_ chore(deps): bump react-hook-form in the react group (#4949) (objectui `33916566e`) -- _(no changeset)_ chore(deps): bump the patch-updates group with 8 updates (#4946) (objectui `37e561f49`) -- _(no changeset)_ docs(skills): 三份指南改教发布态 style.css 导入,停教扫 node_modules 源码 (#4866) (objectui `5d7a655f6`) -- _(no changeset)_ fix(lint): honour the `_` prefix convention in no-unused-vars (#4835) (#4844) (objectui `815cad03d`) -- _(no changeset)_ chore(tsconfig): drop six dead compilerOptions.paths entries and guard the table (#4804) (#4825) (objectui `610de765f`) -- _(no changeset)_ docs(fields): grid.mdx / location.mdx teach the registered plugin types (#4796) (#4822) (objectui `80aa1ac6f`) -- _(no changeset)_ docs(skills): auth-permissions 的 hidden 示例改用 data. 根,并写清表达式作用域 (#4813) (objectui `64553a851`) -- _(no changeset)_ test(config): drop four dead vitest alias entries and pin that every target exists (#4802) (objectui `f2dc8fa96`) -- _(no changeset)_ docs(skills,docs,site): 教学面停止教 `props` 信封,键提回节点 (#4786) (#4800) (objectui `5a3c06afb`) -- _(no changeset)_ docs(skills,site,agents): 教学面改用声明键 columns,停止教未声明的 cols (#4011) (#4785) (objectui `db8184cd9`) -- _(no changeset)_ feat(lint): ban OData query params nested under a dead `options` key (objectui#4734) (#4741) (objectui `af460fb6d`) -- _(no changeset)_ chore(components): remove unread, unpublished component.yml metadata files (#4732) (objectui `baa89a1ea`) -- _(no changeset)_ fix(examples): add required value keys to basic-accordion.json items (#4729) (objectui `099656deb`) -- _(no changeset)_ docs(accordion): rewrite Schema block to match the real AccordionSchema/AccordionItem interface (#4727) (objectui `466ce43b5`) -- _(no changeset)_ fix(i18n): apply the count reservation per direction, not to shared holes (#4719) (objectui `966235ebe`) -- _(no changeset)_ fix(scripts): bound shadcn-sync registry requests with a 10s timeout (#4715) (objectui `21c4e5236`) -- _(no changeset)_ fix(examples): pin the two plugin-calendar catalog entries onto their own events (#4627) (#4667) (objectui `aab597df9`) -- _(no changeset)_ fix(docs): toggle-group schema teaches selectionType, not type, for selection mode (#4665) (objectui `6d4f5bd59`) -- _(no changeset)_ fix(examples,scripts): regenerating the schema catalog stops discarding curated metadata (#4637) (objectui `1111fa1c0`) -- _(no changeset)_ docs: bump QUICK_REFERENCE.md Current Release to 17.5.0 (#4643) (objectui `902227752`) -- _(no changeset)_ chore: release packages (#4126) (objectui `5bf5d2cb6`) - - - -objectui range: `665661ab0932...82a94170c405` diff --git a/.changeset/console-route-jsdoc-spelling.md b/.changeset/console-route-jsdoc-spelling.md deleted file mode 100644 index dee640f21a..0000000000 --- a/.changeset/console-route-jsdoc-spelling.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): correct stale `/console/…` route spellings to `/_console/…` in JSDoc comments on `FormViewSchema.submitBehavior` (view.zod.ts) and the `type: 'form'` action target doc (action.zod.ts) — the mount is `CONSOLE_PATH = '/_console'`, no bare `/console` route resolves. Comment-only; accept/reject behaviour is unchanged (#9078) diff --git a/.changeset/console-spec-dist-injection.md b/.changeset/console-spec-dist-injection.md deleted file mode 100644 index 0d69f6fe0d..0000000000 --- a/.changeset/console-spec-dist-injection.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/console": patch ---- - -fix(devx): the vendored Console SPA bundles THIS tree's `@objectstack/spec`, so a newly declared authorable key is reachable in the Studio designer on the day it lands (#8134) - -`scripts/build-console.sh` injected only `OBJECTSTACK_CLIENT_DIST`. The console's -`@objectstack/spec` therefore always came from objectui's own lockfile, resolved -under `pnpm install --frozen-lockfile` — which means the **published** spec, never -this workspace's. - -That made a whole class of change silently unreachable: an authorable key added to -`packages/spec` after the last spec publish is accepted and round-tripped by the -server, while the Studio designer — bundled against the published spec — rejects it -as an unrecognized key and refuses to auto-save. The framework-side card closes -green the whole time, because `packages/spec`'s own pins pass. Reaching the key took -three ordered cross-repo steps: spec publishes, objectui refreshes its lockfile, the -console pin moves. - -The skew was not hypothetical at the time of this change: **102** schema description -strings declared in this tree's `packages/spec` were absent from the -`@objectstack/spec@17.0.0` the pinned objectui lockfile installs. - -`build-console.sh` now exports `OBJECTSTACK_SPEC_DIST` alongside the client -injection, mirroring it including its preflight: - -- a **hook-presence guard** that refuses the build, naming the pin, when the pinned - objectui predates the `OBJECTSTACK_SPEC_DIST` hook — an unguarded injection would - quietly rebuild the exact silent skew this change exists to end; -- a **build guard** that builds `packages/spec` when it is not built, keyed on both - `dist/index.mjs` and `json-schema/openapi.json`, because the spec's exports map - has one entry (`./openapi.json`) that a different generator produces; -- a **bundle assertion** that proves the injection actually landed. - -The assertion is deliberately not a frozen literal like the client's canary. It -derives a witness on every run — a description string this tree's spec has and the -vendored one lacks — and pairs it with a control string both carry, so an absent -witness is told apart from an unbundled entry. A frozen literal would be carried by -the published spec within one release and pass forever while proving nothing, which -is the same silent-pass failure this change removes. - -Consumers see no API change; the shipped console simply matches the framework -release it is published with. diff --git a/.changeset/create-objectstack-drop-stale-description.md b/.changeset/create-objectstack-drop-stale-description.md deleted file mode 100644 index aafdcee04f..0000000000 --- a/.changeset/create-objectstack-drop-stale-description.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"create-objectstack": patch ---- - -Fix scaffolded projects describing themselves as the blank template (#9263) - -`rewriteProjectIdentity` rewrote `id` / `namespace` / `name` in both -`objectstack.config.ts` and `objectstack.manifest.json` from the project name, -but left `description` untouched — every scaffolded project carried the blank -template's own line verbatim ("Minimal ObjectStack environment — a clean -slate for building."), confidently wrong rather than empty, and printed by -the first command the getting-started flow tells people to run (`os -validate`). - -The scaffolder now drops `description` from both files instead of rewriting -it. There is nothing but the project name to derive a replacement from, and a -name-derived sentence (e.g. "Support Desk — an ObjectStack environment.") -would be a bare restatement of the `name`/`displayName` row already shown — -worse than no sentence at all. `os validate` already omits the description -line entirely when the field is unset, so a freshly scaffolded project now -prints cleanly: - -``` - Support Desk v0.1.0 -``` - -instead of - -``` - Support Desk v0.1.0 - Minimal ObjectStack environment — a clean slate for building. -``` diff --git a/.changeset/d5-recertification-claim-withdrawn.md b/.changeset/d5-recertification-claim-withdrawn.md deleted file mode 100644 index 674bbe23a5..0000000000 --- a/.changeset/d5-recertification-claim-withdrawn.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -fix(security): the ADR-0091 D5 attestation columns stop claiming a recertification review the platform does not run (#9046) - -`last_certified_at` and `certified_by` are declared on both grant tables -(`sys_user_permission_set`, `sys_user_position`) as the ADR-0091 D5 -recertification *substrate*. A whole-tree sweep over `packages/`, `apps/` and -`examples/` — every `.ts`/`.tsx`, tests included — finds the pair in exactly two -kinds of place: those two declarations and the generated i18n bundles carrying -their strings. **No producer and no consumer.** Nothing stamps either column, -nothing reads either one, and no surface derives "never certified" or -"certification stale" from them. The sweep is not blind: the sibling ADR-0091 -columns on the same objects all resolve to real enforcement — `valid_from` / -`valid_until` through `isGrantActive` at resolution time, `reason` and -`delegated_from` through the delegated-admin gate and the security-posture lint. - -Their descriptions nonetheless stated D5's intent as though it were the -behavior — *"When this grant was last attested in a recertification review. Null -= never certified"* and *"Reviewer who last attested this grant."* Access -recertification is a compliance control (SOX / ISO 27001 access review), so that -misreading is the expensive kind: an admin walking `plugin-security`'s objects, -or an AI agent authoring against this model, takes a populated `Last Certified -At` as evidence of a review the platform never performed and never checked. - -ADR-0049 enforce-or-remove, settled the way `sys_capability.active` was -(maintainer ruling, 2026-08-13): **the claim is withdrawn in prose.** Building -the review workflow is a designed feature with no measured pull, and dropping -shipped columns costs a migration over existing rows while buying nothing the -prose fix does not — the harm here is the promise, not the storage, and a -description is one line to change back if D5 is ever implemented. The columns, -their types and their storage are untouched; no producer and no consumer is -added, deliberately. - -Both descriptions now state the inertness outright rather than merely omitting -the promise, so a reader who remembers the old wording is told it was wrong -instead of being left to infer it. All four locale bundles carry the corrected -text. 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-admin-authentication-floor.md b/.changeset/datasource-admin-authentication-floor.md deleted file mode 100644 index 1f07d1e6fa..0000000000 --- a/.changeset/datasource-admin-authentication-floor.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/service-datasource": patch ---- - -fix(security): the datasource-admin HTTP family requires authentication (#9391) - -Every route `registerDatasourceAdminRoutes` mounts under `/api/v1/datasources` -— the list, the single read, the driver catalog, remote-table introspection, -the two connection probes, the credential migration, and create / patch / -remove — now answers `401 UNAUTHENTICATED` to a caller whose identity cannot be -resolved. The refusal is made before any service is resolved and before any -handler body runs, so an anonymous request reaches neither the datasource -lifecycle nor a configured remote. - -This family mounts straight onto `IHttpServer` from a plugin `init()`, which is -outside both seams that produce the platform's 401s: the REST server's -`enforceAuth` runs inside `RestServer`'s own handlers, and the dispatcher -domains' anonymous floor runs inside the dispatcher. Neither is a middleware a -direct mount can be routed through, and the registrar carried no check of its -own — so on a server where `/api/v1/data`, `/api/v1/meta`, `/api/v1/batch` and -`/api/v1/security/explain` all refuse an anonymous caller, this one family did -not. - -The guard imports rather than restates both halves of the decision: -`shouldDenyAnonymous` (the one anonymous-deny decision every HTTP seam shares, -so this family cannot drift on who counts as anonymous) over -`resolveAuthzContext` (the one identity resolution `RestServer` and the runtime -dispatcher perform, so every credential kind the platform admits — better-auth -session and `sys_api_key` alike — is admitted here too). It fails closed: -anything that throws or resolves to no identity is refused, and there is no -posture, config key or absent service that opens the routes. - -**Why this is a fix and not a feature, and why `patch` rather than a breaking -bump.** The change only ever narrows the accept set: every request admitted -after it was admitted before, and the requests it now refuses are exactly the -ones every sibling family already refuses. Nothing authorable is renamed, -retired or tombstoned, and no declared contract changes shape — the routes' -paths, request bodies, success payloads and existing failure codes are -untouched, so there is no ADR-0087 conversion to register and no upgrade -prescription to write. What changes is that a declared expectation starts being -enforced. A caller that depended on reaching platform datasource configuration -with no credential was depending on the defect. - -Authentication is the whole of it. Whether these routes should further require -a platform-configuration capability is a separate, separately-ruled question -(#9593) and is deliberately not anticipated here. - -Pinned by a both-sides test on one boot (`admin-routes-auth-guard.test.ts`): an -anonymous caller is refused on every read and on every write verb, and an -entitled caller still succeeds on the same routes in the same run — the second -half being what distinguishes a guarded family from a broken one. 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-postgres-url-unparseable-refused.md b/.changeset/datasource-config-postgres-url-unparseable-refused.md deleted file mode 100644 index 2d6af650d0..0000000000 --- a/.changeset/datasource-config-postgres-url-unparseable-refused.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): refuse a postgres `config.url` that `pg` itself cannot parse at publish (#9091) - -**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, -#9040 and #9041; the migration prescription is registered under protocol major -18, where `os migrate meta` users will look). - -`PostgresConfigSchema.url`'s own describe text documents the postgres URL -grammar (`postgresql://[user@][host][:port][/dbname][?params]`) and, until now, -enforced none of it: the value was only string-scanned for credentials -(#8082/#8337) and placeholders (#8336). That leniency is deliberate at the -SHARED helper — its refusal to parse is load-bearing for mongo's -multi-host/`+srv` forms (#8696) — but for postgres it amounted to no check at -all. Measured on `pg@8.22.0`: both `pg-connection-string`'s `parse` and `pg`'s -`ConnectionParameters` throw `TypeError [ERR_INVALID_URL]` on -`postgresql://app@h1:5432,h2:5433/app` (node-postgres does not implement -libpq's multi-host DSN), yet the schema accepted that exact value — the -operator discovered the datasource could never connect only at connect time, -via a bare `Invalid URL` whose `input` field `pg` redacts. - -The schema now asks `pg`'s own grammar at publish — a per-driver `superRefine` -on the postgres `url` runs `parse` from `pg-connection-string` (the parser `pg` -itself uses; now a dependency of `@objectstack/spec`) — and refuses, at the -value's path: - -- anything `parse` throws on (multi-host DSNs, non-numeric ports, malformed - percent-escapes), with the parser's own message quoted; -- a scheme-less non-URL, which `parse` only "accepts" by resolving it against - its placeholder base (`postgres://base`) — pg would connect to the literal - host `base` with the authored text as the database name; -- the fs-reading query parameters `?sslcert=` / `?sslkey=` / `?sslrootcert=`, - which make `parse` itself call `fs.readFileSync` — a publish verdict must - not depend on the validating host's filesystem, and certificate material - already has its declared home in the datasource-level `ssl` block (the same - prescription the config-level `ca`/`cert`/`key` keys carry). - -Every measured shape `pg` genuinely opens stays accepted byte-identically: -single-host URLs (credential-free ones included), the empty-host libpq forms -(`postgresql:///db`, `postgresql://user@/db`), unix-socket spellings (a -leading-`/` path, `socket:`, a percent-encoded socket host), IPv6 hosts, and -non-credential/non-fs query parameters. Mongo, mysql and turso URLs are -untouched — the shared helpers keep refusing to parse, per-driver by design. - -## FROM → TO - -```yaml -# before — parsed green; `pg` then threw a redacted `Invalid URL` at connect -driver: postgres -config: - url: postgresql://app@h1:5432,h2:5433/app - -# after — point the URL at a single host (or a proxy/pooler in front of the -# cluster); `pg` does not implement libpq's multi-host DSN, so no spelling of -# it can connect -driver: postgres -config: - url: postgresql://app@h1:5432/app -``` - -There is deliberately no automatic rewrite: a URL `pg` cannot parse does not -carry enough structure to say which single host the author meant (a multi-host -DSN names several on purpose), so the choice of target is the author's. -Runtime-environment DSNs (`OS_DATABASE_URL` and friends) never pass through -this publish door and are unaffected by construction. - - 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-composed-no-username-refused.md b/.changeset/datasource-credentialsref-mongo-composed-no-username-refused.md deleted file mode 100644 index 62f2cbee1d..0000000000 --- a/.changeset/datasource-credentialsref-mongo-composed-no-username-refused.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): refuse the contradictory pair "`external.credentialsRef` bound + a composed mongo config naming no `username`" at publish (#9147) - -**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, -#9040 and #9041; the migration prescription is registered under protocol major -18, where `os migrate meta` users will look). - -The COMPOSED-branch twin of #9041, and the last unserved corner of the "absence -must be loud" half of the #8696 family. #9041 refused a bound -`external.credentialsRef` beside a mongo `config.url` whose userinfo names no -user; its fences deliberately scoped that to the URL branch, leaving the same -defect one branch over still accepted: - -```yaml -driver: mongodb -config: { database: events, host: mongo.internal } -external: { credentialsRef: sys_secret:01J9ZK4T2N } -``` - -With no `config.url` the driver factory COMPOSES the connection URI from the -discrete fields, and the bound secret has exactly one route into it — the -userinfo written beside a username (`buildMongoUrl`: `const auth = user ? … : -''`). A falsy `username` closes that route, and this branch has no second one: -`buildMongoAuth` returns early when there is no `url`, because the composed -branch injects THROUGH the URI it builds rather than beside it. So the artefact -above parsed green, connected **anonymously**, and told the operator nothing — -byte for byte the defect #9041 closed, one branch over. Both branches were -measured to agree on this input before either was refused, so this inherits -#9041's ruling rather than re-opening it. - -The refusal is a one-condition widening of the same datasource-level -refinement (the one door that sees both halves at once), pathed at -`config.username`, and it names BOTH valid authoring fixes without prescribing -either. Its message is deliberately **not** #9041's: there `config.url` -supersedes the discrete `username` so the only fix is the URL's userinfo, while -here `config.username` is the live field — a refusal naming a remedy that does -not apply is worse than no refusal. - -**Scope fences, each measured**: mongodb arm only, legacy `driver: 'mongo'` -rows judged identically via `resolveDriverId` (the postgres arm is not widened -to — #8873 measured `pg` receiving the bound password regardless of the DSN -naming a user); "names no username" is `undefined` **or** `''`, the two -spellings that are falsy at the composer's `user ?` test and therefore drop the -secret identically (note the deliberate asymmetry with #9041's present-but-empty -userinfo carve-out: there `MongoClient` itself throws, so the shape is already -loud, while `username: ''` here connects — silently); a non-string `username` is -the driver-config gate's finding, not this one; an empty-string `credentialsRef` -is not a binding (mirrors the connect path's truthy check); a composed config -that names a user is untouched — that is the branch #8696 already works on. - -Also corrected while redrawing this boundary: **an empty `config.url` is the -composed branch, not the URL branch.** `buildMongoUrl` opens `if (explicit) -return explicit;`, so `url: ''` falls through and composes from the discrete -fields — but #9041's arm judged it as a URL "naming no user" and refused it even -with a live discrete `username`, i.e. rejected at publish a datasource that -connects authenticated at runtime. Both arms now split on the factory's own -branch test, so each judges exactly the branch that will run. - -## 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: { database: events, host: mongo.internal } -external: { credentialsRef: sys_secret:01J9ZK4T2N } - -# after (authenticated intent) — name the user; the bound secret is -# interpolated beside it into the composed URI at connect (#8696) -driver: mongodb -config: { database: events, host: mongo.internal, username: svc } -external: { credentialsRef: sys_secret:01J9ZK4T2N } - -# after (anonymous intent) — drop the binding that could never land -driver: mongodb -config: { database: events, host: mongo.internal } -``` - -There is deliberately no automatic rewrite: the two fixes are contradictory -intents — authenticate (name the user) versus anonymous (drop the binding) — -and choosing between them requires knowing what the datasource is for. - - 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/declared-endpoints-flow-status-table.md b/.changeset/declared-endpoints-flow-status-table.md deleted file mode 100644 index b0a9c37e58..0000000000 --- a/.changeset/declared-endpoints-flow-status-table.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/runtime": minor ---- - -fix(runtime): a declared `type: 'flow'` endpoint answers the #9378 flow-dispatch status table, from the one shared definition (#9462) - - - -**BREAKING** for any caller that reads a declared endpoint's flow result out of -the response body instead of the HTTP status. - -`POST /api/v1/apps//` with `type: 'flow'` used to answer -`200` for every outcome, with the raw engine result in `data` — so a flow that -was disabled, had no start node, could not be found, or ran and was rejected all -reached the caller as `{"success":true,"data":{"success":false,…}}`. That is the -double envelope #3962 removed from `POST /api/v1/actions/:object/:action`, and -it was still standing on the surface an app publishes as its own public API: a -client branching on the HTTP status read every one of those failures as a -success. - -It now answers the same four rows the other two flow doors answer, read from the -one shared definition in `packages/runtime/src/flow-dispatch-status.ts` rather -than from a third private copy of the rule: - -| engine exit | reality | the endpoint answers | -|:---|:---|:---| -| flow not found | never dispatched | `404` | -| flow disabled | never dispatched | `409` `FLOW_DISABLED` | -| flow has no start node | never dispatched | `422` `FLOW_NO_START_NODE` | -| ran and was rejected | ran, rejected | `400` `FLOW_FAILED` | - -What a caller sees differently: - -- **A failed or refused flow is now a 4xx.** The body is the platform's declared - error envelope, `{"success":false,"error":{"code","message","httpStatus"}}`; - there is no inner `data.success` left to read. A caller that already branched - on the status now sees the failure it was previously told was a success; a - caller that branched on `data.success` gets the same fact from `error.code`. -- **A `400` carries the run's own artefacts** in `error.details` - (`errorMessage`, `summary`), exactly as `POST /api/v1/automation/:name/trigger` - carries them. The three never-dispatched rows carry neither, because no run - happened to describe. -- **A successful run is unchanged** — still `200` with the result in `data`. -- **An `outputMapping` declaration is no longer applied to a failure.** The - projection was already restricted to answers with a status below 400, so the - refusal rows fall outside it by the rule that was already written. This closes - a real hole: an `outputMapping` used to be applied to the `200`-wrapped failure - body and could present a refused dispatch as data. -- Both policy behaviours keyed on the same test move with it: `cacheTtl`'s - `Cache-Control` no longer rides a flow failure, and the `rateLimit` / - `authRequired` chain is untouched — it runs before execution either way. - -This is the third and last door of the #9446 ruling (maintainer, 2026-08-18, -verbatim 「同意」: the status table is a property of the flow-dispatch CONTRACT, -not of the trigger route). All three doors now read one definition, and the -suite asserts that by driving the same engine result through all three and -comparing. diff --git a/.changeset/default-timeout-margin-repair.md b/.changeset/default-timeout-margin-repair.md deleted file mode 100644 index ad760a2952..0000000000 --- a/.changeset/default-timeout-margin-repair.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/types": patch -"@objectstack/dogfood": patch ---- - -fix(tests): give two default-vitest-timeout cases real margin instead of a bare default (#9311) - -Two cases only passed `pnpm test` when they were not competing for CPU — the -same defect class as the already-closed precedents #3662, #4186, #4485, -#5421, #6329: a test running under vitest's **default** `testTimeout` / -`hookTimeout` with no margin for anything heavier than an idle box. - -**`packages/types/src/node.test.ts`** — `"falls back to the importing -package's own resolution when the host does not declare"` is the only case -in the file that performs a real dynamic `import()` of `@objectstack/spec` (a -multi-megabyte package); every sibling in the same `describe` block resolves -a small on-disk fixture or fails fast, all under 10ms. Measured on this box: -~0.9-1.1s unloaded, already observed failing at 5061ms against the 5000ms -default under nothing heavier than `turbo run test --concurrency=2` (#9311's -own isolation runs). Gave that one case an explicit 30s `testTimeout` — the -same order of magnitude the repo already uses for subprocess/real-load cases -(`#3662` precedent) — and left every sub-10ms sibling alone. - -**`packages/qa/dogfood/test/semantic-roles.dogfood.test.ts`** — its -`beforeAll` boots the full showcase stack (ObjectQL + ~45 plugins) through -`@objectstack/verify`'s `bootStack`, which does not fit vitest's 10s -`hookTimeout` default with any margin at all: observed failing at 10027ms -against the 10000ms budget, and this file's own isolated run measured 18.3s -(vitest `Duration`) / 19.5s wall clock for the whole file even with the box -otherwise idle. Gave the hook an explicit 180s timeout, matching this -package's own existing house pattern for the identical -`bootStack(showcaseStack, …)` call -(`admin-identity-audit-trail.dogfood.test.ts`'s `beforeAll(…, 180_000)`) -rather than inventing a new number for the same operation. - -**No behaviour change** — both suites already pass; this only gives the two -timeout-sensitive cases room to finish on a loaded box. The repo's full test -suite is confirmed green at low concurrency (#9311), so this is margin -repair, not a product fix. `turbo.json`'s default concurrency is out of scope -for this change (a maintainer-level default, per #9311's own filing). diff --git a/.changeset/deferred-ddl-bounded-lock-wait.md b/.changeset/deferred-ddl-bounded-lock-wait.md deleted file mode 100644 index b173e4438e..0000000000 --- a/.changeset/deferred-ddl-bounded-lock-wait.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): a blocked `os migrate` now refuses in 120s instead of hanging for a year on a MySQL metadata lock (#9354) - -The deferred-DDL flush widens legacy MySQL `TIMESTAMP` columns to `DATETIME(3)` -and `TIME` to `TIME(3)` with `ALTER TABLE … MODIFY COLUMN`, which needs an -**exclusive metadata lock** on the table. That ALTER ran on a session inheriting -MySQL's default `lock_wait_timeout` — **31,536,000 seconds, one year**. A single -other session holding a lock on the table (a long-running transaction, an open -uncommitted session, a stuck report query) parked the ALTER in -`Waiting for table metadata lock` for that long, and nothing printed. - -An operator running `os migrate apply` against a busy production table met this -as a command that simply hangs — indistinguishable from a crash, with no output -to diagnose from. It was first measured as a CI stall: a sub-second test blew a -5000ms budget with **no error at all**, because the ALTER just sat in a lock wait -until vitest killed the process. - -Two things were wrong, and a bound alone would have fixed neither: - -- **Nothing bounded the wait.** `lock_wait_timeout` had zero occurrences - anywhere in `packages/`. -- **The widening swallows its failures.** That policy is right on boot — a - migration must never take boot down, and correctness never depended on the - widening having run — but on the flush it means `os migrate apply` reports - success for work it did not do. - -The flush now runs its widening ALTERs on **one pinned connection**, bounds -`lock_wait_timeout` to **120 seconds** on that same session, and lets exactly one -condition escape the swallow: a metadata-lock timeout is re-thrown as an ADR-0112 -envelope — `DATABASE_ERROR` / 500, from the existing closed vocabulary — whose -message names the lock wait, the table, the bound it hit, and how to find the -holder. `os migrate apply` prints that message and exits 1. - -The connection pinning is the load-bearing half: `lock_wait_timeout` is a SESSION -variable, so a `SET SESSION` issued through the pool lands on a connection the -ALTER never uses — a no-op that looks exactly like a fix. - -**120 seconds** is chosen as a diagnosis deadline, not a capacity knob: three -orders of magnitude above the milliseconds a normal OLTP transaction holds a -metadata lock (so an ordinary busy table never trips it), and still inside the -window where the operator is watching the command. The widening is idempotent, -so the cost of firing too eagerly is one re-run. - -Unchanged, deliberately: boot schema-sync still runs unbounded and still -swallows; every non-lock-wait failure during the flush keeps the swallow it had. -No retry logic and no configurability — both wait for measured demand. diff --git a/.changeset/dependabot-9212-production-dependencies-patch.md b/.changeset/dependabot-9212-production-dependencies-patch.md deleted file mode 100644 index ee61d154ee..0000000000 --- a/.changeset/dependabot-9212-production-dependencies-patch.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/cli": patch -"@objectstack/driver-memory": patch -"@objectstack/driver-mongodb": patch -"@objectstack/driver-sql": patch -"@objectstack/driver-sqlite-wasm": patch -"@objectstack/driver-turso": patch -"@objectstack/metadata": patch -"@objectstack/plugin-auth": patch -"@objectstack/plugin-email": patch -"@objectstack/plugin-hono-server": patch -"@objectstack/plugin-pinyin-search": patch -"@objectstack/service-settings": patch ---- - -chore(deps): production-dependency patch bumps from the weekly Dependabot group (#9212) - -Routine dependency-range refresh, no behavior change: `@oclif/core` 4.13.2→4.13.3, -`esbuild` 0.28.1→0.28.2 and `better-sqlite3` ^13.0.2→^13.0.3 (optional) on -`@objectstack/cli`; `mingo` 7.2.2→7.2.4 on `@objectstack/driver-memory`; `nanoid` -6.0.0→6.0.1 on `@objectstack/driver-mongodb`, `@objectstack/driver-sql`, -`@objectstack/driver-sqlite-wasm` and `@objectstack/driver-turso`, plus -`better-sqlite3` ^13.0.2→^13.0.3 (optional on `@objectstack/driver-sql`, peer on -`@objectstack/driver-turso`); `js-yaml` 5.2.2→5.2.3 on `@objectstack/metadata`; -`@noble/hashes` 2.2.0→2.3.0 and `jose` 6.2.5→6.2.8 on `@objectstack/plugin-auth`; -`nodemailer` 9.0.3→9.0.5 on `@objectstack/plugin-email`; `@hono/node-server` -2.0.12→2.1.1 and `hono` 4.12.34→4.13.2 on `@objectstack/plugin-hono-server`; -`pinyin-pro` 3.28.2→3.29.1 on `@objectstack/plugin-pinyin-search`; and -`@noble/ciphers` 2.2.0→2.3.0 on `@objectstack/service-settings`. - -Every entry above changed a `dependencies`, `optionalDependencies` or -`peerDependencies` range in the published manifest — the only kind of change -that reaches a consumer's install. The same Dependabot group also bumped -`devDependencies` on `@objectstack/hono`, `@objectstack/client`, -`@objectstack/core`, `@objectstack/plugin-sharing` and `@objectstack/spec` -(none consumer-facing), and touched the private `apps/docs`, -`examples/app-todo` and workspace-root manifests (none published) — none of -those get an entry here. diff --git a/.changeset/derive-org-membership-levels.md b/.changeset/derive-org-membership-levels.md deleted file mode 100644 index 00a9b0369e..0000000000 --- a/.changeset/derive-org-membership-levels.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`ORG_MEMBERSHIP_LEVELS` is now derived from `BUILTIN_MEMBERSHIP_ROLES` instead of hand-spelling a copy, so the `org_membership_level` approver vocabulary is exactly the `sys_member.role` vocabulary. Accept-set widening: `delegated_admin` (ObjectStack's own ADR-0105 D8 tier, already storable and enforced on `sys_member.role`) is now offered by the approver picker and valid as an `org_membership_level` approver value. The constant's provenance doc-comment is corrected in the same change: the list is ObjectStack's closed membership vocabulary (ADR-0108), no longer "better-auth's closed set". 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/discovery-per-request-protocol.md b/.changeset/discovery-per-request-protocol.md deleted file mode 100644 index 604f607d1d..0000000000 --- a/.changeset/discovery-per-request-protocol.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): `/discovery` describes the request's environment, not the control plane (#9292) - -`registerDiscoveryEndpoints`' handler opened with `this.protocol.getDiscovery()` — the -**control-plane** protocol captured at construction — while roughly thirty sibling -handlers in the same file obtain theirs from `resolveProtocol(environmentId, req)`. -Everything else in the handler composes over that one document, so the entire body -followed the host's kernel. `/discovery` is the surface SDKs, codegen and AI clients read -to decide what a deployment can do. - -**Yes, an observed document changes** — on multi-environment and single-environment -deployments. On a control-plane-only boot nothing changes at all. - -The sharper half is the **scoped** route. `registerRoutes` registers the same closure for -the unscoped base and for `.../environments/:environmentId`, so -`GET /api/v1/environments/abc/discovery` — a request naming its environment in the URL — -still received the control plane's document. - -**Measured on a two-kernel host before the fix**, with real `getDiscovery()` producers -per environment: two environments with genuinely different kernels received -**byte-identical** `capabilities`, `services` and `locale`, and both received the -*host's* answers rather than either environment's. For the richer of the two tenants that -meant all 13 capability keys wrong (`transactionalBatch`, `automation`, `cron`, `export`, -`comments`, `analytics`, `ai`, `i18n` each reported `false` while the environment -delivered them), its whole `services` map wrong, its `locale` wrong (`en` reported for an -environment serving `zh-CN`), four of its real route keys missing, and a phantom -`routes.notifications` advertised that no tenant could serve. - -That is wider than a two-capability defect because the builder derives the whole document -from its own kernel: the `services` map and the optional `routes` keys come from that -kernel's service registry, `locale` from its `i18n` occupant, and the capabilities from -its engine and registry. - -The unscoped route reaches the same shared resolution -(`resolveRequestEnvironmentId`, ADR-0076 D11 step ④) rather than getting a special case, -and keeps the control-plane answer exactly where that is the correct one: with no -environment in scope the chain returns `undefined` and `resolveProtocol` falls through to -the captured control-plane protocol. A single-environment boot resolves through step 3 -(the default provider) and now describes the kernel that actually serves its data; a -hostname-routed multi-tenant host follows the same authority the HTTP dispatcher uses, so -`/discovery` and the data routes beside it describe one kernel. - -Two halves of the handler were already correct and are unchanged: the `realBase` route- -string substitution and the trailing `scoping` block already read -`req.params.environmentId`. The `version` field is overwritten from server config on -every request and never followed the wrong protocol either. diff --git a/.changeset/dispatcher-error-vocabulary-lowercase-and-helper-shapes.md b/.changeset/dispatcher-error-vocabulary-lowercase-and-helper-shapes.md deleted file mode 100644 index 644b5b6f66..0000000000 --- a/.changeset/dispatcher-error-vocabulary-lowercase-and-helper-shapes.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -fix(tooling): `check:dispatcher-error-vocabulary` reports lowercase codes and the two stamp positions it could not see — a live 403 had been invisible to both vocabulary gates (#9460) - -The gate's published bound said it scanned "only SCREAMING_SNAKE literals", and -handed lowercase to `check:error-code-casing`. Half of that delegation was real -and half was a hole, and the hole is where `plugin-security`'s live 403 -`owd_widening_forbidden` sat through two ADR-0112 sweeps: both gates read the -file, both reported nothing, each leaving it to the other. - -**The card's premise was that the scan's patterns are case-sensitive. They are -not** — the literal shapes already matched `[A-Za-z]`. Two explicit filters -dropped the value after the match, and the producer was invisible for a -different reason entirely, so the prescribed one-line widening would not have -found it. Measured before changing anything: reporting every lowercase stamp -took the scan from 12 sites to 94, and **all 82 new findings were D6/D6b/D6c -neighbours or Zod's own issue codes** — it would have called -`ctx.addIssue({ code: 'custom' })` an unregistered ObjectStack error code. - -So lowercase is now reported **except** in the two positions where -`check:error-code-casing` reads the identical characters (`code: 'x'`, -`.code = 'x'`). There the delegation is genuine: that gate carries the -D6/D6b/D6c discrimination this one does not have. Everywhere else — a constant, -a template, a helper parameter — there is no quoted literal at the stamp site -for a `code`-anchored pattern to match, that gate is structurally blind, and -dropping the value reported it to nobody. What is measured is now "outside the -vocabulary **and** unowned by the gate we delegate lowercase to", never "is it -SCREAMING_SNAKE". - -Three stamp positions the scan could not see, all of them widenings: - -- **`codehelper`** — a file declares one factory and throws through it - everywhere (`postureError(code, message)`, `makeError(status, code, message)`, - a `constructor(code, message)`). The stamp `(err as any).code = code` knows - the token `code` but not the value; the call site knows the value and never - writes the token. Every pattern in **both** gates anchors on that token. The - join is the parameter, so its **index** names the argument to read — derived, - never assumed to be zero, because two live helpers put `code` second and a - first-argument rule reads a number and an English sentence as error codes. -- **`assignconst`** — `err.code = DENY_CODE`, the assign position's constant - sibling. #9223 closed exactly this gap for object literals; the assign - position kept it. -- **`assign`** with a cast on the left. The old anchor demanded a bare - identifier where `(err as any).code = 'X'` puts a `)`. - -The scan goes from 12 classified sites to 18, and from 0 to 2 codes awaiting a -ledger entry — the ratchet moving in the direction it exists to move. -`FLOW_CONVERSION_CONFLICT` (a live 409 from the metadata write path) and -`owd_widening_forbidden` are recorded as `pending-registration`; four -`MigrationJournalRefusal` codes are `boot-refusal` (their only consumers are two -CLI commands, no HTTP boundary). ⛔ No allowlist entry, no narrowed pattern, no -raised ceiling: **registering or renaming a code stays the `packages/spec` -lane's call**, and these rows record the measurement rather than prescribing the -remedy. 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/dispatcher-streaming-fallback-buffered-send.md b/.changeset/dispatcher-streaming-fallback-buffered-send.md deleted file mode 100644 index 4f7a0c9d78..0000000000 --- a/.changeset/dispatcher-streaming-fallback-buffered-send.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -The dispatcher's two write-less-transport fallbacks for streamed results now implement the IHttpResponse streaming contract's own prescription (#3607, ADR-0076 OQ#10): when a transport's response object lacks the optional `write`/`end` streaming surface, the SSE frames are buffered and delivered through `send()` under the streaming headers, byte-identical to what a streaming transport would have written. Previously the route-wrapper fallback answered a bare JSON `{ events }` body no SSE reader could decode, and the dispatch-result writer fell through to serializing the stream descriptor itself — collapsing its `events` AsyncIterable to `{}` and losing every event silently under HTTP 200. Both branches are reachable only through an externally supplied `Runtime({ server })` transport without streaming support; callers of such compositions that parse the streamed and buffered bodies with the same SSE reader now decode identical frames from either. diff --git a/.changeset/driver-sql-backend-fault-envelope.md b/.changeset/driver-sql-backend-fault-envelope.md deleted file mode 100644 index 1090fc22f5..0000000000 --- a/.changeset/driver-sql-backend-fault-envelope.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): a dialect error the driver cannot attribute leaves the read exits as an ADR-0112 backend-fault envelope instead of raw (#8931) - - - -`SqlDriver.find()` / `findOne()` / `count()` had one exit that answered with the -**database's own error object**: a `code` from the backend's vocabulary -(`42P01`, `SQLITE_ERROR`, `42601`, `22P02`, …), **no `status`** at all, and a -message opening with the compiled statement. Two things travelled out of it that -should not have — the statement's shape, and on one measured row the caller's -own value. - -Ruled 2026-08-17 on #8931: the driver stops answering an unenveloped dialect -error. Any dialect error the existing classification does not claim now leaves -as a **generic backend-fault envelope**, `DATABASE_ERROR` / 500, asserting only -*"the backend rejected this statement"*. - -**Not a filter verdict, and that is the ruling rather than a preference.** -Measured live on PostgreSQL 16.13, a dotted WHERE key and a table that was never -created raise the *same* SQLSTATE: - -``` -dotted key 42P01 missing FROM-clause entry for table "title" -table not created 42P01 relation "no_such_object" does not exist -``` - -An `INVALID_FILTER` here would tell an operator whose schema sync had not run -that their *filter* was wrong. The signal cannot support the claim, so the -envelope does not make it — and the driver still never inspects the caller's key -for a `.` (that verdict is #8371's, and it landed there). - -**Mechanism: a terminal catch-all, not a new recognizer.** No predicate learns -`42P01`. `isUnresolvableColumnError` and `isMissingTableError` are untouched, so -the #8790 refusal (`INVALID_FILTER` / 400 naming the column) still wins wherever -it applies, and the #3821 projection / ORDER-BY recoveries still return rows. - -**What now takes the envelope**, measured on live PG 16.13 and better-sqlite3: -a table that was never provisioned; a dotted WHERE key on Postgres; a -comparand-shape syntax fault; a value the column type rejects; and connection, -pool-acquisition, timeout or permission failures. - -**The disclosure this closes on a route nobody had named.** Postgres puts the -caller's rejected VALUE in its own `22P02` diagnostic (`invalid input syntax for -type integer: "…"`), *downstream* of everything knex parameterised — so no -statement cut removes it. Withholding the dialect text whole is what closes it. -(#8931's headline premise, a bound literal inlined on the *dotted* route, was -measured false and pinned by #9108; this is the neighbouring row where a value -really does travel.) - -**The original error is kept as a non-enumerable `cause`.** That is load-bearing, -not tidiness: `isMissingTableError` follows `cause`, and thirteen read paths use -it to tell "the table was never provisioned" — a benign emptiness — from a -failure that must stay loud. Non-enumerable so the statement cannot ride back -out through `JSON.stringify(err)` or a spread. - -**For callers.** At the REST boundary the wire answer for these conditions is -materially unchanged — `mapDataError` already derived `500` + `DATABASE_ERROR` -for them by sniffing the message; it is now *declared* by the producer that -knows, per ADR-0112, and every non-REST consumer (an in-process ObjectQL caller, -a plugin, an AI-authored action) gets the same declared answer instead of having -to pattern-match a SQLSTATE that differs per backend. Two consequences worth -naming: code that matched on the raw dialect `code` or message of a failing -**read** must read `error.cause` instead; and a read against a **registered -object whose table was never created** now answers `500 DATABASE_ERROR` where it -previously answered `404 OBJECT_NOT_FOUND` with the body `Object 'x' is not -registered` — a sentence that was false in exactly that state. 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-readme-shipped-surface.md b/.changeset/driver-sql-readme-shipped-surface.md deleted file mode 100644 index 3ba4e41bd6..0000000000 --- a/.changeset/driver-sql-readme-shipped-surface.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -docs(driver-sql): rewrite the published README to the shipped driver surface (#9867) - -`packages/drivers/driver-sql/README.md` is in the package's `files` array with -`private` unset, so it is the page npm renders. It told the reader to build a -stack with a static factory on a class that is not exported, at three call sites: - -```ts -driver: DriverSQL.configure(getDatabaseConfig()) -``` - -Measured against the built `dist/index.d.ts`: `DriverSQL` occurs **zero** times, -and no `configure` static exists on `SqlDriver` or on anything else the package -exports. `DriverSQL.configure()` was never real — the commit that first repaired -these snippets elsewhere in the same file (2026-05-07) called it "the imaginary -`.configure(...)` static factory", and it fixed only the Basic Usage section, so -the page has contradicted itself since: correct `SqlDriver` import at line 43, -fabricated `DriverSQL` at 448/482/516. The receiver is a free identifier that -imports nothing, which is why `check:published-readme-exports` — both halves of -which key on an *imported* name — could not see it. - -Renaming would not have produced working code, and the sweep this card asked for -found the surrounding shape was fabricated too. Every claim on the page was -re-measured; the ones that were wrong: - -- **`defineStack({ driver: … })` does not exist** — the six `driver:` call sites - (three `new SqlDriver(...)`, three `DriverSQL.configure(...)`) all named a key - `ObjectStackDefinitionSchema` never declared. Since #8687 that schema is - `.strict()`, so it does not merely drop the key: `defineStack` **throws** - (`Unrecognized key(s) on this stack definition: 'driver'`), and `tsc` refuses - the literal with `TS2353`. A driver is a plugin — - `plugins: [new DriverPlugin(new SqlDriver({ … }))]`, `DriverPlugin` from - `@objectstack/runtime`. The env-var route (`OS_DATABASE_URL`) is documented - alongside it. -- **Four of the six documented driver methods do not exist.** `driver.raw()` (six - call sites) is `execute()`; `checkConnection()` (two) is `checkHealth()`, which - resolves `false` rather than throwing, so the try/catch example was wrong in - shape as well as in name; `destroy()` is `disconnect()`; `transaction(cb)` is - `beginTransaction()` + `options.transaction` + `commit()`/`rollback()`, and the - callback's `trx.insert({ object, data })` names nothing at all. `getKnex()` was - the only one that resolved. -- **`kernel.getDriver()`** — three call sites; `ObjectKernel` has no such member - (`getDriver` is *private* on the engine). -- **The query AST was wrong in three places.** `find` takes the object name as - its first argument, so `find({ object, … })` is an arity error; the filter key - is `where` with the ObjectQL dialect (`{ amount: { $gte: 10000 } }`), not - `filters: [{ field, operator, value }]`; and sorting is - `orderBy: [{ field, order }]` — `sort`/`direction` is the spelling - `SortNodeSchema` lists as a retired alias. -- **The config type name was wrong.** The page declared - `interface SQLDriverConfig`; the export is `SqlDriverConfig` - (`TS2724 … Did you mean 'SqlDriverConfig'?`), it is `Knex.Config` plus four - ObjectStack keys, and all four — `schemaMode`, `autoMigrate`, - `sqliteJournalMode`, `sqliteAbsentFile` — were undocumented. -- **A config block that could not load.** The tenant-field example wrote - `tenancy: { enabled: true, strategy: 'shared', … }`; `tenancy.strategy` was - removed after spec 15.0 (#2763) and is now a tombstone that rejects with a - prescription. -- **The environment-config example did not compile even setting the fabricated - factory aside** — `configs[env]` with `env: string` is `TS7053`, and `ssl` sat - at the top level of the config, where Knex does not read it (it belongs to - `connection`). -- **Every raw-SQL example queried tables that do not exist.** The physical table - name *is* the namespace-prefixed object name (`crm_account`, `sys_user`); - nothing is prefixed `objectstack_`. -- **The Migrations section documented an off-platform workflow** — a `knexfile.js` - plus `npx knex migrate:latest`. Schema is reconciled from object metadata - (`schemaMode: 'managed'`, `autoMigrate`), reviewed with `os migrate plan` and - applied with `os migrate apply`; indexes are declared on the object - (`indexes: [{ fields, unique }]`), not issued as DDL. The "always use - migrations, never raw DDL" best-practice line said the opposite of how the - platform works. -- **A dead import.** The Vercel example imported `createClient` from - `@vercel/postgres` and never used it. - -All 19 TypeScript fences on the rewritten page are extracted verbatim and -compiled against the built `.d.ts` files the `exports` maps resolve; the two -`defineStack` shapes are additionally executed. Docs only — no runtime code -changed and no API was added. 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/durability-log-level-callee-shapes.md b/.changeset/durability-log-level-callee-shapes.md deleted file mode 100644 index a8b49c394a..0000000000 --- a/.changeset/durability-log-level-callee-shapes.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/plugin-audit": patch -"@objectstack/plugin-email": patch -"@objectstack/plugin-security": patch ---- - -A durability failure reported to a logger without `error` is no longer lost - -Six degradation reports — a lost `sys_audit_log` row (CRUD, auth-event and -read-audit writers), a stranded `sys_email` row, and the two permission-set -metadata backfill failures — were spelled `logger?.error?.(…)`. `error` is -declared OPTIONAL on those sinks, and an optional call emits nothing at all when -the method is absent: a host injecting a `{ info, warn }` logger received no -report whatsoever, on exactly the paths whose whole point is that nothing else -looks broken afterwards. - -Each now reaches for `error` and falls back to `warn`, never to silence. The -message, its consequence and its fix are identical on both channels; only the -level degrades, and only when the sink cannot do better. - -`AuthEventAuditLogger` additionally declares the `warn?` method it needs for -that fallback, matching `ReadAuditLogger`, which always had it. The addition is -optional, so no existing sink stops satisfying the interface. 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/element-filter-lint-residue.md b/.changeset/element-filter-lint-residue.md deleted file mode 100644 index 136103907f..0000000000 --- a/.changeset/element-filter-lint-residue.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): drop the `element:filter` entry from `COMPONENT_FIELD_SPECS` (#9220) - -The whole `element:filter` element retired at element grain (ADR-0049 — no -renderer ever shipped for it), so every `ElementFilterProps` key is a -`retiredKey()` tombstone and no spec-conformant page carries `fields` on it. -The field-binding rule's job (resolve a field NAME against the object) is not -the question a retired key raises: an authored key is already reported by name -with the element-retirement prescription through the #5068 props gate, and the -binding entry would only add a second finding about a key that no longer -exists — the #5775/#6629 residue class the package's own -`component-field-specs-liveness` gate refuses. diff --git a/.changeset/element-filter-retired.md b/.changeset/element-filter-retired.md deleted file mode 100644 index af18de7e65..0000000000 --- a/.changeset/element-filter-retired.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire the `element:filter` element at element grain (#9220, ADR-0049) - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). - -`element:filter` never had a renderer or reader anywhere. Measured at -retirement (objectstack `2f65b1b42`, objectui `5ffcc14`; cloud per the origin -card's recorded sweep): objectui registers no renderer for it — its -`renderers/basic/elements.tsx` header deferred the element to "owning plugins" -that never materialized — Studio's designer palette carries it as a no-renderer -`PALETTE_EXCLUSIONS` entry ("list surfaces own filtering (userFilters / filter -builder)"), and the 2026-06 page-liveness audit recorded it rendering "Unknown -component type". Every one of its six authorable keys — `object`, `fields`, -`targetVariable`, `layout`, `showSearch`, `aria` — was a capability claim -nothing kept: an author (human or AI) who configured a filter element got a -success receipt for a component that renders nothing (the ADR-0078 shape). -\#9198 retired `targetVariable` per-key on the two input elements and recorded -this wider finding; per-key retirement would have been the wrong grain here, so -the whole element retires at once. - -**What is refused:** any authored key on `element:filter` `properties`. All six -keys are `retiredKey()` tombstones — refused at `tsc` (typed `never`) and at -the parse, message carrying the element-grain prescription. The -`ComponentPropsMap` row deliberately STAYS so the #5068 props gate keeps -dispatching on the type and refusing loudly — deleting the row would demote the -type to an unregistered custom string the gate deliberately skips. - -**What stays accepted:** a bare `element:filter` node with empty `properties` -(the migrated shape) still parses at the node level — `PageComponentSchema.type` -is an open union, so a node-level refusal is not expressible; the node was -always inert and stays inert. `element:filter` is removed from the -`PageComponentType` enum (de-advertisement — docs, palette derivations, and the -authorable vocabulary), which changes no parse outcome. Filtering on list -surfaces is unchanged and was never this element's: use a view's `userFilters` -quick-filter bar or the list toolbar's filter builder. - -The retirement kit: - -- tombstones at the schema (`packages/spec/src/ui/component.zod.ts`), enum - removal at `packages/spec/src/ui/page.zod.ts` -- ADR-0087 registration: retired-key entries `ui/ElementFilterProps:object` / - `:fields` / `:targetVariable` / `:layout` / `:showSearch` / `:aria` and the - D2 conversion `element-filter-removed` (protocol 18), wired into the step-18 - chain — `os migrate meta --from 17` strips the keys from old sources (pure - lossless deletes; none ever had an effect to lose) and leaves the bare node -- the #9198 conversion's negative-control fixture moves from `element:filter` - to an open-union custom type (same assertion — the strip dispatches on the - component type, not the key name) -- pin tests (`component.test.ts` — refusal carries the prescription; the - bare migrated node parses clean and materializes nothing) -- generated baselines/docs follow the schema (`authorable-surface/`, - `json-schema.manifest/`, spec-changes, upgrade guide, reference docs) - -## FROM → TO - -```ts -// before — parsed green; nothing anywhere rendered it -{ - type: 'element:filter', - properties: { object: 'order', fields: ['status'], layout: 'sidebar' }, -} - -// after — delete the component; filtering belongs to the list surface -// (view.userFilters / the list toolbar's filter builder) -``` - - diff --git a/.changeset/element-input-target-variable-retired.md b/.changeset/element-input-target-variable-retired.md deleted file mode 100644 index 538481c04e..0000000000 --- a/.changeset/element-input-target-variable-retired.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire the inert `targetVariable` key from `element:text_input` and `element:record_picker` (#9198, 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). - -`targetVariable` on the two SDUI input elements was a declarative hint with -zero readers in any repo — its own describe text said the live binding -"resolves via the variable whose `source` equals this component id" -(`PageVariableSchema`), and that reverse lookup -(`usePageVariableBinding(schema?.id)` in the console renderer) is the only -binding mechanism that exists. Measured (objectstack-ai/objectui#3834, -re-verified at retirement): no renderer, hook or runtime in objectui, -framework or cloud reads the key. An author — human or AI — who read the -manifest, wrote `targetVariable`, and skipped the variable's `source` got an -input that wrote nothing, with a success receipt and no diagnostic anywhere. -Same disposition as the sibling inert hint settled by retirement in objectui -PR #4794. - -**What is refused:** an authored `targetVariable` on `element:text_input` or -`element:record_picker` properties. Both keys are `retiredKey()` tombstones — -refused at `tsc` (typed `never`) and at the parse, message carrying the -prescription. - -**What stays accepted:** every text input / record picker without the key, -byte-identically — including the working binding (`variables[].source`), which -is untouched. `targetVariable` on `element:filter` is a different surface and -is not part of this disposition. Runtime behaviour is unchanged: nothing ever -read the key, so removing it removes no behaviour. - -The retirement kit: - -- tombstones at the schema (`packages/spec/src/ui/component.zod.ts`) -- ADR-0087 registration: retired-key entries - `ui/ElementTextInputProps:targetVariable` + - `ui/ElementRecordPickerProps:targetVariable` and the D2 conversion - `element-input-target-variable-removed` (protocol 18), wired into the step-18 - chain — `os migrate meta --from 17` strips the key from old sources (pure - lossless delete; it never had an effect to lose) -- pin tests (`component.test.ts` — refusal carries the prescription; clean - parses materialize nothing) -- generated baselines/docs follow the schema (`authorable-surface/`, - `json-schema.manifest/`, spec-changes, upgrade guide, reference docs) - -## FROM → TO - -```ts -// before — parsed green; the hint bound nothing -{ - id: 'email_input', - type: 'element:text_input', - properties: { inputType: 'email', targetVariable: 'contact_email' }, -} - -// after — delete the key; declare the binding on the page variable instead -{ - id: 'email_input', - type: 'element:text_input', - properties: { inputType: 'email' }, -} -// page.variables: [{ name: 'contact_email', type: 'string', source: 'email_input' }] -``` - - diff --git a/.changeset/email-render-only-seam.md b/.changeset/email-render-only-seam.md deleted file mode 100644 index f74e51f19e..0000000000 --- a/.changeset/email-render-only-seam.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/plugin-email": minor -"@objectstack/service-messaging": minor ---- - -feat(messaging): `IEmailService` gains a render-only `renderTemplate({ template, locale, data, timezone }) → { subject, html, text }`, and the inbox channel consumes it — localized `sys_email_template` content now reaches `sys_inbox_message` (#9225) - -A template-path notify node with `channels: ['inbox', 'email']` delivered a -localized email and an inbox row whose title was the topic and whose body was -empty: the locale ladder + `{{var}}` renderer (ADR-0053 format filters -included) lived inside plugin-email's `sendTemplate`, unreachable without -sending mail (maintainer-ruled seam, 2026-08-17, on #9225). - -- `IEmailService.renderTemplate` (new contract method, `packages/spec`) - resolves a `sys_email_template` bundle by `(name, locale)` with the same - documented en-US ladder as `sendTemplate`, validates required variables, and - returns the rendered `{ subject, html, text }` — strictly render-only: no - transport call, no queueing, no `sys_email` row. Implemented ONCE in - plugin-email by extracting the resolver `sendTemplate` already used; - `sendTemplate` now delivers what the shared resolver renders, byte for byte. -- The messaging inbox channel consumes it the way the email channel consumes - `sendTemplate`: a delivery whose payload carries a notify `template` - reference renders `subject` into the row's `title` and `text` into - `body_md`, per recipient, at delivery time. A registered email service - without the method — or no email service at all — fails the delivery LOUDLY - (`TEMPLATE_UNSUPPORTED`, graded permanent) instead of silently degrading to - topic-as-title; renderer failure codes (`TEMPLATE_NOT_FOUND` / - `TEMPLATE_INACTIVE` / `MISSING_VARIABLES`) land on the delivery row and are - graded permanent, mirroring the email channel. - -The result shape follows what `sys_email_template` rows carry -(`subject`/`body_html`/`body_text?`): `html` is the rendered `body_html`, -`text` is the rendered `body_text` or, when the row declares none, derived -from the rendered HTML. diff --git a/.changeset/email-service-core-service-name-jsdoc.md b/.changeset/email-service-core-service-name-jsdoc.md deleted file mode 100644 index 198e4e2394..0000000000 --- a/.changeset/email-service-core-service-name-jsdoc.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): correct false "Aligned with CoreServiceName '…'" JSDoc claims across `packages/spec/src/contracts/*.ts` — `email-service.ts` now names the real `'email'` runtime slot registered by `@objectstack/plugin-email` (not a `CoreServiceName` member; subsumed under `'notification'`), and `export-service.ts` / `seed-loader-service.ts` now state plainly that they have no evidenced `CoreServiceName` slot or registration binding (`seed-loader-service.ts`'s companion "SeedLoaderProtocol in data/seed-loader.zod.ts" claim was also fabricated — no such export exists). The other 13 template instances were checked against `CoreServiceName` and left byte-identical; they are true. Comment-only; accept/reject behaviour is unchanged (#9752) diff --git a/.changeset/endpoint-route-401-code-key.md b/.changeset/endpoint-route-401-code-key.md deleted file mode 100644 index 0e4017db98..0000000000 --- a/.changeset/endpoint-route-401-code-key.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/runtime": minor ---- - -feat(security): the endpoint-route 401 anonymous-deny body carries `code: "UNAUTHENTICATED"` alongside the existing `error` / `message` keys (#9823) - -Service-declared endpoint routes (`RouteDefinition` emitted via hooks, e.g. -`buildAIRoutes()` — any route whose `auth` is not explicitly `false`) answered -an anonymous caller `401 { error, message }` with no `code` key: the -`mountRouteOnServer` 401 arm wrote an inline copy of the flat deny body, so -the #9487 constant change (`@objectstack/core`'s `ANONYMOUS_DENY_BODY`) never -reached it, and through `@objectstack/client` a caller's `err.code` stayed -`undefined` for exactly these 401s. - -The arm now writes the shared `ANONYMOUS_DENY_BODY` / `ANONYMOUS_DENY_STATUS` -verbatim, so the body gains `code: "UNAUTHENTICATED"` and this seam can no -longer drift from the constant it was copied from. **Additive only** -(maintainer-ruled on #9487): no key is removed or moved — `error` keeps -holding the same code value it always has, so every existing reader keeps -working. This does not settle ADR-0112 D5 (flat vs nested envelope -convergence, #9559); the envelope family of this arm is unchanged in kind. 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-code-ledger-stored-type-not-canonical-two-producers.md b/.changeset/error-code-ledger-stored-type-not-canonical-two-producers.md deleted file mode 100644 index e2fbf83209..0000000000 --- a/.changeset/error-code-ledger-stored-type-not-canonical-two-producers.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): `STORED_TYPE_NOT_CANONICAL` ledger comment names both producers and their atomicity (#9361) - -The ledger entry at `error-code-ledger.zod.ts:381` described `STORED_TYPE_NOT_CANONICAL`'s -only producer as the publish pre-flight ("refused at the publish pre-flight, batch-atomic"). -PR #9360 (#9174) added a second producer — `revertCommit`'s restore limb, which refuses -per-item on its existing `failed[]` channel and is explicitly NOT batch-atomic — leaving the -comment naming one of two producers, with the wrong atomicity for the one it omitted. - -The comment now names both: the publish pre-flight (batch-atomic, `#8908`) and -`revertCommit`'s restore limb (per-item on `failed[]`, NOT batch-atomic, `#9174`). - -Text-only change — accept/reject behavior, the error code, and its envelope are all -unchanged. 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/explain-partial-mask-reporting.md b/.changeset/explain-partial-mask-reporting.md deleted file mode 100644 index 3b9e4b8e4f..0000000000 --- a/.changeset/explain-partial-mask-reporting.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -fix(security): security explain reports partial masking — the field-mask layer gains the third state instead of calling gated fields hidden and gate-less rule fields readable (#9127) - - - -#8993 landed partial masking on the enforcement channel: a field declaring -`maskingRule` is no longer deleted from a masked caller's response, its value -is **replaced** (`13812345678` → `138****5678`), with the field's -`requiredPermissions` acting as the unmask gate. The access-explanation -engine's field-mask layer predates that and read only the binary mask, so on -the one surface whose whole job is to describe enforcement it stated two -things that were not true: - -- a field with `maskingRule` **and** a `requiredPermissions` gate the caller - does not hold was listed under *"N field(s) masked from responses"* — an - admin reading the report concluded the key was absent, while the caller was - in fact receiving the partially masked value; -- a field with `maskingRule` and **no** gate was reported under *"No - field-level masking applies"* — invisible in the report, and masked for - every non-system caller in reality. - -Both directions matter, and they fail opposite ways: the first overstates the -protection in place, the second hides that any applies at all. - -The `fls` layer now reports the three states the enforcement path actually -produces — **hidden** (key deleted), **partially masked** (key served, value -replaced, the applicable rule named) and **readable** — and answers `narrows` -whenever either dimension bites, where a gate-less rule previously produced -`not_applicable`. - -**Mirrored, not re-derived.** The composition deciding which rules apply to a -caller — `computePartialMaskRules` AND the explicit-deny exclusion that a -permission-set `readable: false` still wins outright — is lifted into one -method on the plugin (`computeReadPartialMaskRules`) that the result-masking -middleware, the readable-field projection and now explain all call. The -hidden/partial split in the report is `FieldMasker.maskResults`' own rule -(`!(field in rules)`), so the report cannot disagree with the masking it -describes. A second, independent derivation inside the explain engine is -exactly how this drift opened in the first place; `security-service.ts`'s -module contract claims explain *"matches enforcement by construction"*, and -this restores that for the partial-mask dimension. - -**Breaking for direct embedders of the engine** (hence `minor`, not `patch`): -`ExplainEngineDeps` gains a **required** `getPartialMaskRules`. It is required -rather than optional on purpose — the field-mask decision has three outcomes -and the existing binary `getFieldMask` can express only two, so an engine -wired without it would silently reproduce both misreports above. A compile -error is the correct way for that omission to surface. Callers going through -`SecurityPlugin` / the `security` service's `explain()` — every consumer in -this repo — need no change. 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/expression-bindable-text-keys.md b/.changeset/expression-bindable-text-keys.md deleted file mode 100644 index d80cde3605..0000000000 --- a/.changeset/expression-bindable-text-keys.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -Declare the closed vocabulary of expression-bindable text keys (objectui#4795 Direction 1, spec half — #9599). - -`@objectstack/spec/ui` now exports `EXPRESSION_BINDABLE_TEXT_KEYS` (`title` / `label` / `value` / `description` — a closed enum per the 2026-08-17 maintainer ruling's terms, reopened 2026-08-18), the `ExpressionBindableTextKey` type and `ExpressionBindableTextKeySchema` Zod face, the per-component carriage map `EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT` (`statistic`: `label`/`value`/`description`, `card`: `title`/`description`, `button`: `label` — measured against the objectui renderers' read points at the `.objectui-sha` pin), and the runtime lookup `expressionBindableTextKeysFor(componentType)`. These are consumed by the objectui SchemaRenderer evaluation memo (the downstream half, riding objectui#4795) so the set of top-level text keys the memo evaluates is declared here once, never inferred or hard-coded as a twin list. Purely additive — no existing schema accepts or rejects anything new in this release. diff --git a/.changeset/external-datasource-federation-auth-floor.md b/.changeset/external-datasource-federation-auth-floor.md deleted file mode 100644 index 03a3e40898..0000000000 --- a/.changeset/external-datasource-federation-auth-floor.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(security): the external-datasource federation HTTP family requires an authenticated caller, on every route (#9686) - - - -`registerExternalDatasourceRoutes` mounts the five federation routes -(`GET .../external/tables`, `POST .../external/tables/:remote/draft`, -`POST .../external/tables/:remote/import`, `POST .../external/refresh-catalog`, -`POST .../external/validate`) straight onto `IHttpServer`, so they pass through -none of the seams that produce the platform's 401s: `RestServer.enforceAuth` is -a private method invoked inside that server's own handlers — not middleware a -direct mount is routed through — and the dispatcher domains' floor runs inside -the dispatcher. Being composed by `RestServer` was not itself a guard. - -**The missing piece was an edge in the composition, not a line in a handler.** -`mountAndRecordDirectRoutes` resolves the `RestServer`'s execution-context -resolver and handed it to ONE of the two registrars it mounts: -`registerPackageRoutes` got the identity and applied the shared anonymous floor, -`registerExternalDatasourceRoutes` got nothing and checked nothing. The resolver -now reaches both, and the federation registrar applies the same floor: - -- the **decision** is `shouldDenyAnonymous` (`@objectstack/core`), the one - function every HTTP seam on the platform shares — `isSystem` is not settable - from the wire and a CORS `OPTIONS` preflight passes, both by its construction; -- the **identity** is the `RestServer`'s own resolver, which admits every - credential kind the platform admits — a better-auth session *and* a - `sys_api_key`. This family is SDK-expressed (`datasources.external.*` on - `ObjectStackClient`), so a floor that read only a session would have refused - callers the rest of the surface accepts; -- it **fails closed**: anything that throws, and anything resolving to no - identity, is refused. No configuration, posture or absent service opens it; -- the check runs **before** the service lookup, so an anonymous caller cannot - learn from a `503` which services a deployment has wired — and, on the two - routes that change state, the refusal provably precedes the write; -- the 401 is written through this surface's shared `sendError`, so the status, - code and message are the platform's while the envelope stays this family's. - -**A pinned equivalence is restored, not merely an exposure closed.** -`GET .../external/tables` and `GET /api/v1/datasources/:name/remote-tables` reach -the same `listRemoteTables`; `POST .../external/tables/:remote/draft` and -`POST /api/v1/datasources/:name/object-draft` reach the same -`generateObjectDraft`. #4249 gave those two spellings one failure contract and -#7955 one request shape. After the datasource-admin family grew its own floor -(#9391), one operation answered 401 at one spelling and served anonymously at -the other. `remote-tables-twin.equivalence.test.ts` now compares the two on the -admission axis as well, so a guard added to one spelling and not the other fails -whichever side it is added to. - -Authentication and nothing more: whether these routes should further require a -capability is the separately-ruled question #9593 asks of the admin family, and -is deliberately not folded in here. 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-reference-target-unanswerable.md b/.changeset/field-reference-target-unanswerable.md deleted file mode 100644 index 78d0372fc2..0000000000 --- a/.changeset/field-reference-target-unanswerable.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -Refuse `GET /api/v1/meta/field/./references` instead of clearing it for deletion - -A `field` metadata item is addressed by the composite key `.` (e.g. `account.owner`), but every metadata property that names a field holds the **bare** field name — `view.list.columns[].field`, `dataset.dimensions[].field`, `object.validations[].field`, `object.fields{}` and 150 further non-recursive paths across nine source types. The two sides are drawn from disjoint vocabularies, so the reference scan answered `{ references: [] }` for every field, on every deployment, regardless of real usage. - -The admin "Used by" panel renders that empty answer verbatim as *"Nothing in the metadata graph points at this item. Safe to delete."* — an unanswerable question shown as a positive clearance, on the screen where someone decides to delete. - -`findReferencesToMeta` now refuses a `field` target with `501 NOT_IMPLEMENTED` in the ADR-0112 envelope, carrying the answerable alternative (`GET /api/v1/meta/object//references`). Per ADR-0110 D3, a miss and a fault are different facts. Nothing is added to the success response, and no new error code is introduced — this is the same code the route already returns when the protocol cannot compute the graph at all. - -Every other target type is unaffected: a genuine "nothing points at this item" still answers `{ references: [] }`. 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/flat-door-declared-code.md b/.changeset/flat-door-declared-code.md deleted file mode 100644 index 0236b97a43..0000000000 --- a/.changeset/flat-door-declared-code.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -fix(rest): the flat error responder narrows a thrown `code` to the declared ADR-0112 vocabulary, demoting an unregistered spelling to `declaredCode` (#9232) - -**If you read `error.code` off a `packages/rest` flat error body today, read this.** - -`packages/rest` answers errors in the flat dialect — `{ error: 'message', code: 'X' }`, -with `code` at the body's top level. Until now, when that body came from a *caught* -error, whatever string the producer had put on `.code` was copied to the wire verbatim, -including spellings that are not members of the ADR-0112 error vocabulary -(`StandardErrorCode` plus the registered ledger). Every other HTTP door in the platform -had already stopped doing that. - -It stops here too. A thrown `code` is now resolved exactly as the dispatcher door -resolves it, by the same shared `resolveThrownHttpError` / `demotedDeclaredCode` pair: - -- **A registered code is unchanged.** It still arrives in `code`, verbatim, with nothing - added beside it. If your branches read registered codes — and every consumer branch - measured in this repo, the SDK and the console does — nothing about your code changes. -- **An unregistered code is demoted.** `code` now carries the vocabulary member the HTTP - status derives (a 403 gives `PERMISSION_DENIED`, a 409 `RESOURCE_CONFLICT`, and so on), - and the producer's own spelling moves, unchanged, to a new top-level `declaredCode` - field beside it. Nothing is lost — but a branch written against an *unregistered* - spelling in `code` will stop matching, and must read `declaredCode` instead. - Presence of `declaredCode` means demotion: it is absent whenever the producer's code - was recognised. -- **A throw that declared no code still carries none.** Narrowing the vocabulary does not - start inventing codes for bodies that had none. -- **A non-string `code` no longer reaches the body at all.** A numeric driver errno could - previously land in `code`; it was never a legal value there and is now treated as - context, as it already was at every other door. - -The observable case in this repo: the object-posture gate's `403 owd_widening_forbidden` -now answers `{ code: 'PERMISSION_DENIED', declaredCode: 'owd_widening_forbidden' }`. That -body could not previously satisfy the schema it claimed to satisfy. - -The error body's **position** is unchanged — this dialect still puts `code` at the top -level rather than in `error.code`. Converging the position is a separate, still-open line -held by the `check:route-envelope` ratchet, and was explicitly not a precondition here. diff --git a/.changeset/flow-action-refusal-carrier.md b/.changeset/flow-action-refusal-carrier.md deleted file mode 100644 index 3e74514665..0000000000 --- a/.changeset/flow-action-refusal-carrier.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/runtime": minor ---- - -feat(runtime): a flow ACTION that ran and failed now carries the flow author's `errorMessage` and the run `summary` in `error.details` at the `/actions` door (#9585) - -Dispatching a flow through `POST /api/v1/actions/:object/:action` (a `type: 'flow'` -action — the documented way to expose a flow on a record page) answered `400 -FLOW_FAILED` with only the raw engine error. The trigger door -(`POST /api/v1/automation/:name/trigger`) additionally ships two things in -`error.details` of the ADR-0112 envelope: `errorMessage` — the failure text the -flow's AUTHOR wrote for exactly this case (`flow.errorMessage`, the single field -the console reads, objectui `flowResponse.ts`) — and `summary`, the run's -per-node accounting that says WHICH node failed. At the action door the author's -text was declared-but-never-delivered. - -Maintainer ruling (2026-08-19, Option B on #9585): `dispatchFlowAction` now -throws a typed refusal carrier (`FlowActionRefusal`) on the ran-and-failed row, -and the `/actions` handler recognises it ahead of its generic catch, serving -`errorMessage` and `summary` exactly as the trigger door does — same field -names, same source, pinned door-against-door so the two cannot drift apart -again. Bounded deliberately: - -- the shared `resolveThrownHttpError` (`@objectstack/types`) stays untouched — - its closed `details` list remains the rule for every other thrower; no - general "any throw declares wire payload" widening; -- only the ran-and-failed row carries the artefacts — a never-dispatched - refusal (404 / 409 `FLOW_DISABLED` / 422 `FLOW_NO_START_NODE`) has no run to - report and ships neither, at both doors; -- a caller that does not recognise the carrier (the MCP `run_action` bridge) - serves exactly the previous answer — the carrier stamps `status`, `code` and - `message` identically to the plain throw it replaces; -- no new schema keys; both doors keep agreeing on `400 FLOW_FAILED`. diff --git a/.changeset/flow-terminal-messages-every-run-doc.md b/.changeset/flow-terminal-messages-every-run-doc.md deleted file mode 100644 index 6474c797c0..0000000000 --- a/.changeset/flow-terminal-messages-every-run-doc.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): `FlowSchema.successMessage`/`errorMessage` describe themselves as carried on every terminal flow run, not screen-flow-only (#9512) - -Since #9414, the pair is set on `AutomationResult` for every terminal run — -`execute()`'s exit, both `retryExecution()` exits, and the resume exit — not -only on `screen`-flow runs. The JSDoc and `describe()` text above -`successMessage`/`errorMessage` in `packages/spec/src/automation/flow.zod.ts` -previously said "Terminal messages for `screen`-flow runs", which stayed the -premise of a route considered and rejected at #9414's triage (narrowing the -contract to screen-flow-only). Text-only: no schema shape, validation, or -`authorable-surface.base.json` change. The two mirrored reference pages -(`content/docs/references/automation/flow.mdx`, -`content/docs/references/api/automation-api.mdx`) are regenerated to match. 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/gantt-viewmode-declared.md b/.changeset/gantt-viewmode-declared.md deleted file mode 100644 index 9600fcb9b3..0000000000 --- a/.changeset/gantt-viewmode-declared.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -Declare `viewMode` on `GanttConfigSchema` (the `gantt` view block): an optional enum of the gantt renderer's measured granularity vocabulary — `'day' | 'week' | 'month' | 'quarter' | 'year'`. The member list is measured from objectui `plugin-gantt` (`GanttView.tsx` `GanttViewMode` / `VIEW_MODES`), not invented. No spec-side default on purpose: the renderer resolves an omitted `viewMode` through its persisted-layout seeding before falling back to `'day'`, so a materialized default would read as an explicit author choice. Spec half of the objectui#5074 both-branches ruling (#9463); an out-of-vocabulary value is now refused at authoring instead of silently falling back. diff --git a/.changeset/getmetaitems-helper-request-literals-typed.md b/.changeset/getmetaitems-helper-request-literals-typed.md deleted file mode 100644 index d38062235f..0000000000 --- a/.changeset/getmetaitems-helper-request-literals-typed.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -refactor(rest): the non-door `getMetaItems` request literals are compiled against the declared contract (#9805) - -Nine `getMetaItems` call sites in `packages/rest/src/rest-server.ts` outside the -four meta-read doors still passed their request through `as any` (or through a -`p: any` parameter), so the compiler checked nothing about them. Every member -they thread has been expressible in declared types since #9741 landed -`previewDrafts` on `GetMetaItemsRequest` and the `TransportScopedMetaRequest` -envelope for the transport-level `environmentId` — the casts were pure -blindness, and an un-typed request literal is exactly the class that lets a -future key drift silently. - -Each literal is now a named const typed -`TransportScopedMetaRequest` (or plain -`GetMetaItemsRequest` where the site threads no `environmentId`), the same shape -#9741 gave the doors: the object-metadata read behind the API-exposure gate, the -audience book fetch, the book-tree book and doc listings, the doc corpus behind -the audience resolver, the public-form view lookup, the public-form object -schema, the public-lookup reference resolution, and the dataset listing. - -**No behaviour change of any kind, and nothing about the wire moves.** The -outgoing payloads are byte-identical (same keys, same conditional spreads); the -edit hoists each literal into a const and drops a type-level cast. Two spellings -at these sites deliberately SURVIVE, because retiring either would change -behaviour rather than typing, and both are now documented on the envelope alias: - -- the optional call (`getMetaItems?.(…)`) and the `typeof … === 'function'` - guards — `getMetaItems` is a required `MetadataProtocol` member, so these are - not feature detection in the type sense, but a host may occupy the protocol - slot with an object that does not implement the whole surface (the reason - `metaTypeIsLive` documents the same spelling for `getMetaTypes`). Retiring one - turns a tolerated absence into a `TypeError`; -- the result handling — the verb is declared to return `{ type, items }` while - these sites also tolerate the bare-array shape older hosts and stubs return, - so the response stays runtime-shaped on purpose. - -Genuinely feature-detected server-only verbs (`getMetaDiagnostics`, -`listDrafts`, `migrateStoredMetadata`, …) are untouched — runtime casts are the -documented convention there, and tightening one would turn optional capability -detection into a hard dependency. diff --git a/.changeset/hono-adapter-discovery-envelope.md b/.changeset/hono-adapter-discovery-envelope.md deleted file mode 100644 index 3289c6c33d..0000000000 --- a/.changeset/hono-adapter-discovery-envelope.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/hono": minor ---- - -feat(adapters): the hono adapter's two discovery bodies join the response envelope (#9436) - - - -`GET {prefix}` and `GET {prefix}/discovery` answered `{ data: }` -with no `success` flag — one key short of the declared `BaseResponseSchema` -envelope. They now answer `{ success: true, data: }`. - -Maintainer ruling on #9436 (2026-08-18, option A), deliberately not inheriting -#9389's pre-auth exemption: these bodies are read by SDKs, codegen and AI -clients — the envelope's core constituency — rather than by our own shells, -and the migration is one key. Readers that unwrapped `body.data` keep working -unchanged; envelope-aware readers that discriminate on `success` now unwrap -this mount correctly. diff --git a/.changeset/http-requests-total-transport-seam.md b/.changeset/http-requests-total-transport-seam.md deleted file mode 100644 index 7276ef93ea..0000000000 --- a/.changeset/http-requests-total-transport-seam.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/plugin-hono-server": minor ---- - -fix(hono-server): `http_requests_total` is emitted by the transport, so every inbound mount is counted (#9650) - -The counter had exactly one emitter: a `Proxy` the runtime dispatcher built -over its **own** `IHttpServer` handle. It therefore saw only the routes the -dispatcher itself registered — and nothing else on the same server. - -Measured, that left at least **14** inbound surfaces uncounted, in two -structurally different classes: - -- plugins that mount through `getRawApp()` — auth (`/api/v1/auth/*`), - metadata HMR, cloud-connection, marketplace, runtime-config, trigger-api, - webhooks, approvals, the console SPA. These bypass `IHttpServer` entirely, - so **no** wrapper at that level can ever reach them. -- plugins that resolve `http.server` themselves and mount through the verb - methods — the REST data API via `RouteManager`, storage, i18n, settings, - datasource admin. - -The two highest-traffic surfaces an operator actually cares about, auth and -the REST data API, were both in that set. The documented guidance is to alert -on the 5xx rate derived from this counter, so a deployment could be melting -down on `/api/v1/*` with the counter flat. - -The counter is now emitted from the Hono adapter itself, as a raw-app -middleware installed at the end of `HonoServerPlugin.init()` beside -`installMiddlewareSeam()` — the one layer every inbound request converges on, -whatever registered the handler. - -**The route label is the matched PATTERN, never the concrete path.** -`/api/v1/data/:id`, not one series per record id; `/api/v1/auth/*`, not one -per sign-in endpoint. Cardinality has to stay bounded or the counter is -unusable for the alerting it exists for, and the label is unfixable in place -once dashboards are wired against the first shipped one. - -**Wiring.** `HonoServerPlugin` takes a new `observability.metrics` option and -otherwise follows the canonical chain `ObservabilityServicePlugin` documents: -explicit option, then the `observability:metrics` service, then **nothing** — -with no backend configured no middleware is installed at all, so an -unconfigured deployment pays no per-request cost. - -**Two consequences, stated rather than left to be discovered:** - -- **A transport that does not implement this seam reports no HTTP metrics.** - The seam is Hono's. Another `IHttpServer` implementation emits nothing until - it grows its own, and a zero there means "not instrumented", never "no - traffic". A response-observing hook on the `IHttpServer` contract is the - transport-agnostic successor and is filed separately. -- **A request the `use()` chain refuses is still counted** — the seam is - installed before the middleware seam, so the inbound rate limiter's `429` - appears with `status="429"`. A preflight `OPTIONS` that the transport's own - CORS built-in answers is **not** counted: it short-circuits earlier and - never reaches a route. diff --git a/.changeset/http-server-response-observation-seam.md b/.changeset/http-server-response-observation-seam.md deleted file mode 100644 index ab166352ab..0000000000 --- a/.changeset/http-server-response-observation-seam.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": patch -"@objectstack/observability": patch -"@objectstack/plugin-hono-server": patch -"@objectstack/runtime": patch -"@objectstack/http-conformance": patch ---- - -feat(spec): `IHttpServer` gains an optional `afterResponse` response-observing -hook so HTTP metrics are transport-agnostic instead of Hono-only (#9835) - -The contract addition (additive — a new optional member plus the -`HttpResponseObservation` / `HttpResponseObserver` types and the reserved -`UNMATCHED_ROUTE_PATTERN` label): a transport invokes each registered observer -exactly once per answered request with `{ method, routePattern, status, -elapsedMs }`, after the response exists — the observation point the `use()` -middleware contract cannot express (it runs before dispatch and never sees a -status). `routePattern` is REQUIRED to be the registered route pattern -(`/api/v1/data/:id`), never the concrete path, so no adapter re-decides metric -cardinality. Optionality is feature-detected runtime-real -(`typeof server.afterResponse === 'function'`); a transport that does not -implement the seam reports **no** HTTP metrics — zero there means "not -instrumented", never "no traffic". - -Implementations and consumers in the same change: - -- `@objectstack/plugin-hono-server`: `HonoHttpServer` implements the seam (the - ruled #9650 raw-app middleware becomes its delivery path — same reach, - including `getRawApp()` mounts and middleware-refused 429s); unrouted - requests are now labelled with the reserved `unmatched` pattern (previously - they could surface as `/*`). -- `@objectstack/observability`: new `armHttpRequestCounter(server, metrics)` - arms the `http_requests_total` counter through the seam at most once per - server (first caller wins), which is what makes "exactly one counter per - server" structural. -- `@objectstack/runtime`: the dispatcher offers its `observability.metrics` - registry to the seam (a host that wires only the dispatcher now counts every - inbound surface) and suppresses its own per-route copy of - `http_requests_total` when the transport implements the seam — retiring the - #9833 double count. Request-id echo, the duration histogram, the error - counter and the error reporter are unchanged. -- `@objectstack/http-conformance`: `NodeHttpServer` implements the seam, and a - new cross-adapter conformance suite locks the semantics for both adapters. -- `@objectstack/core`: re-exports the new contract types/constant. 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/hydrate-overlay-canonical-type.md b/.changeset/hydrate-overlay-canonical-type.md deleted file mode 100644 index 03208bd816..0000000000 --- a/.changeset/hydrate-overlay-canonical-type.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch -"@objectstack/spec": patch ---- - -Refuse a non-canonical metadata `type` at the SchemaRegistry overlay mint door - -`hydrateOverlayIntoRegistry` — the one choke point boot hydration, the read-side -hydration and the write-through all funnel through — minted registry entries under -whatever `type` spelling it was handed, with no fold and no assertion. It now asserts -the spelling is canonical and refuses with `REGISTRY_TYPE_NOT_CANONICAL` (status 500) -when it is not, so an entry can no longer be minted into a second registry namespace -that no canonical read, listing or declaration lookup can reach. - -Four of the six producer routes already folded at the boundary. The two that did not -(boot hydration and `revertCommit`) fold through the manifest-collection map, which -omits the types that are not stack collections — so it resolved the plurals that were -never the hazard and passed through the ones that were. Reachable only from metadata -rows written before the `/meta` URL boundary began folding; such a row is now reported -loudly (counted and named at boot, warned on the write-through) instead of silently -registering under its stored spelling. - -Deliberately an assertion rather than a fold: folding here would honour, process-wide, -the override that the canonical `/meta` door refuses. diff --git a/.changeset/i18n-merge-consequence-documented.md b/.changeset/i18n-merge-consequence-documented.md deleted file mode 100644 index f3ea4ca299..0000000000 --- a/.changeset/i18n-merge-consequence-documented.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -docs(cli): document the i18n merge consequence for a corrected source string (#9672) - -`os i18n extract` merges against committed bundles by default so a re-run never -wipes a hand translation, and `--fill=default` only fills gaps. That means a -source label/description that is later **corrected** does not propagate into a -locale that already holds a translation of the old text — a present-but-stale -string is not a gap, so it is left as-is. This was always the behaviour and -remains unchanged; it was simply undocumented where the next reader looks. - -Two places now say so: the merge-options comment in `os i18n extract`'s command -implementation, and the header comment written into every generated -`.objects.generated.ts` / `.metadata-forms.generated.ts` bundle. -Committed bundles across the repo are regenerated so their header matches the -new template (translated content is unchanged). - -`check:i18n`'s bundle-drift verdict still proves generated output matches the -schema; it does not compare a translated value against its source's meaning -(that remains an open appetite question, tracked separately — see #9672). 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/implement-objectos-rule3-real-exports.md b/.changeset/implement-objectos-rule3-real-exports.md deleted file mode 100644 index 4b7ce749e1..0000000000 --- a/.changeset/implement-objectos-rule3-real-exports.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): `implement-objectos.md` Rule #3 names real exports instead of `RequestEnvelope` / `ResponseEnvelope` (#9614) - -The published runtime-kernel prompt told an implementing agent to "validate -`RequestEnvelope`" and "wrap in `ResponseEnvelope`". Neither has ever been an -export of `@objectstack/spec` — measured across all 16 published -`api-surface/*.json` entries — and `ResponseEnvelopeConfig*` is a config shape, -not the envelope, so it was not a drop-in referent. - -Rule #3 now describes the validation surface the package actually has: - -- **Requests**: there is no single request envelope by design. - `StandardApiContracts` maps each standard operation to its `input` - (`CreateRequestSchema`, `UpdateRequestSchema`, `IdRequestSchema`, - `BulkRequestSchema`, and `QuerySchema` for `list`), and `ApiEndpointSchema` - carries the route's own shape — which is what makes `api/endpoint.zod.ts`, a - file the rule already cited, actually reachable from it. -- **Responses**: `BaseResponseSchema` is the envelope skeleton that each - response type extends with its own `data`, and the rule now carries the - warning the schema's own docstring makes: `safeParse` alone does not prove - conformance, because the schema strips unknown keys and accepts a payloadless - `{ success: true }`. `envelopeViolations(body)` is the conformance check. - -The referents also move from prose into a fenced `import`, so -`check:published-readme-exports` resolves them from now on — the blind spot that -let the two fabricated names ship (prose symbols match neither an import clause -nor a member call site) no longer covers this rule. - -No schema, no runtime behaviour and no authorable surface changes; the published -prompt text does. 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/index-rule-where-slot-names-the-object.md b/.changeset/index-rule-where-slot-names-the-object.md deleted file mode 100644 index b5a3ad24d6..0000000000 --- a/.changeset/index-rule-where-slot-names-the-object.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): the three ADR-0120 uniqueness rules name the object in the `where` slot instead of repeating the config path (#9600) - -`AuthoringFinding` declares two location slots with different jobs — `where` -("human-readable location", e.g. `object "leave_request"`) and `path` ("config -path", e.g. `objects[3].sharingModel`). Three registry adapters set the first -from the second (`where: f.path`), so every CLI command printed the same -positional string twice and the only human-readable slot said nothing the `at` -clause did not already say: - -``` -• objects[44].indexes[1]: "sys_account" declares index [provider_id, account_id] with bare `unique: true` … - rule: unique/unscoped-declared-index at objects[44].indexes[1] -``` - -That index is a position in the MERGED object array, which appears in no file -the author wrote. `unique/unscoped-declared-index`, `unique/double-declaration` -and `unique/legacy-organization-composite` now spell it the way the rest of the -table does: - -``` -• object "sys_account" · index [provider_id, account_id]: "sys_account" declares index … - rule: unique/unscoped-declared-index at objects[44].indexes[1] -``` - -An index is identified by its `name` when it has one, and otherwise by the -columns the author actually wrote (`· index [provider_id, account_id]`) — both -searchable in their source, which a bare ordinal is not. - -`where` is stated by the rule functions themselves rather than reconstructed in -the adapter, because only the rule still holds the object it walked. Their -return type is now `LocatedLintIssue` (a `LintIssue` with a REQUIRED `where`), -newly exported, so a fourth rule joining this family cannot reach the adapter -without one — a `f.where ?? f.path` fallback at the adapter would have let the -positional spelling ship again silently. - -Display text only, and the rules' population is unchanged: measured over the 45 -object declarations `@objectstack/platform-objects` and -`@objectstack/metadata-core` ship, the registry produced 1050 findings from the -same 5 rules before and after, with the count of findings whose `where` was a -bare config path going 72 to 0. `path` is deliberately untouched and stays -positional — it is the slot that is supposed to be a config path, and the -runtime gate's `fingerprint` reads `where` and `path` together, so making -`where` more specific cannot merge two findings that were distinct. diff --git a/.changeset/init-scaffold-owd-and-author-time-rules.md b/.changeset/init-scaffold-owd-and-author-time-rules.md deleted file mode 100644 index 5144f30c6c..0000000000 --- a/.changeset/init-scaffold-owd-and-author-time-rules.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `objectstack init` scaffolds now compile — templates author an OWD, and the scaffold self-test runs the author-time rules (#9666) - -`objectstack init my-app -t app --install` reported `✓ Scaffold validated`, and -the next command in the documented on-ramp, `npm run dev`, failed to compile: - -``` -✗ Author-time rules failed (1 issue) -• object "my_app_item": custom object "my_app_item" declares no sharingModel (OWD)… - rule: security-owd-unset at objects[0].sharingModel -``` - -The CLI's own shipped template was refused by the CLI's own shipped rule set, so -the dev server never started on a freshly generated project. - -Two halves: - -- **Templates author an OWD.** The `app` and `plugin` templates now declare - `sharingModel: 'private'` on the object they emit — the rule's own recommended - default and the ADR-0090 D1 baseline (absence is not a decision). A sweep of - every built-in template found `plugin` in the same state as the reported `app`; - `empty` emits no objects and was already clean. -- **`init`'s self-test got teeth.** It used to check only that the rendered config - loaded and carried a `manifest.namespace`, which is why a template that could - not compile shipped. It now runs the author-time rule registry over the - generated project and refuses to report success when any rule rejects it. The - rule set is the `build` one — the same set `os dev` reaches by spawning - `os compile` — so this is a shift-left, not a stricter bar: nothing that - compiles today stops compiling, and a broken template now fails at generation - time instead of at a user's first `dev`. - -`✓ Scaffold validated` still prints, and now names how many author-time rules -passed. diff --git a/.changeset/init-template-descriptions-match-emission.md b/.changeset/init-template-descriptions-match-emission.md deleted file mode 100644 index 737b1811d7..0000000000 --- a/.changeset/init-template-descriptions-match-emission.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `os init` template descriptions no longer advertise metadata kinds they never emit (#9737) - -The `app` and `plugin` templates' `description` strings — shown by `printKV('Template', …)` -right after `os init -t