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 ` runs, and mirrored in `content/docs/deployment/cli.mdx` —
-claimed views, actions, and extensions that neither template's `srcFiles` map ever writes.
-Both templates emit objects only (`src/objects/index.ts` + `src/objects/{namespace}_item.ts`).
-
-- `app`: `'Full application with objects, views, and actions'` → `'Full application with objects'`
-- `plugin`: `'Reusable plugin with objects and extensions'` → `'Reusable plugin with objects'`
-- `content/docs/deployment/cli.mdx`'s template table drops the same false `views` claim from the
- `app` row.
-
-A new pin (`packages/cli/test/init.test.ts`) asserts every template's description only claims a
-metadata kind (`objects`/`views`/`actions`/`extensions`) its `srcFiles` map actually has an entry
-under, so a future template can't drift the same way.
diff --git a/.changeset/inline-related-columns-strict.md b/.changeset/inline-related-columns-strict.md
deleted file mode 100644
index 3e9fd8fa7e..0000000000
--- a/.changeset/inline-related-columns-strict.md
+++ /dev/null
@@ -1,42 +0,0 @@
----
-"@objectstack/spec": minor
----
-
-feat(spec): strict element schemas for `Field.inlineColumns` and `Field.relatedListColumns` (#9227)
-
-**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 #9221/#9250 precedent).
-
-Both keys were `z.array(z.any())`: every column object validated — right keys,
-wrong keys, misspelled keys, empty objects — so a mis-keyed column published
-clean and surfaced only in the browser, as a grid with the right row count and
-every cell blank (the objectui#3951 failure, reachable from the authoring side).
-
-- `inlineColumns` entries are now `InlineGridColumnSchema` (exported): a
- strict, `name`-keyed column mirroring the objectui inline-grid renderer's
- measured reads — `name` (required), `label?`, `type?`, `width?`, `required?`,
- `options?`, `prefix?`, `step?`, `reference?`, `displayField?`, `idField?`,
- `multiple?`, `accept?`, `defaultHidden?`, `computed?`, `expr?`, `scale?`,
- `autofill?`, `readonlyWhen?`, `requiredWhen?`. Unknown keys are a named
- rejection at publish time; the retired `field` spelling is refused with the
- prescription naming `name` (objectui#3951 aligned the widget to `name` with
- deliberately no tolerant alias). `expr` is the grid evaluator's BARE
- arithmetic string — a CEL envelope there is refused. Identity-only entries
- (`{ name: 'quantity' }`) remain the recommended form: objectui's
- `hydrateColumns` fills everything else from the child object's fields.
-- `relatedListColumns` entries are now child FIELD-NAME STRINGS (e.g.
- `['name', 'status']`) — the only authored form in-repo and the only form the
- related-list renderer hydrates fully (labels, cell types and formatting
- derive from the child object's field definitions); the page-block sibling
- `record:related_list.columns` is the same strings-only shape. A column
- object is refused with a prescription pointing at the child fields.
-
-Migration: respell `{ field: 'x' }` inline-grid columns as `{ name: 'x' }`;
-replace related-list column objects with the child field name string — or run
-`os migrate meta`, which rewrites both mechanically (registered conversion
-`field-column-lists-canonicalized`, protocol 18). The one in-repo usage
-(`examples/app-showcase` invoice line items) is migrated in this change.
-
-
-
diff --git a/.changeset/install-local-capability-gate.md b/.changeset/install-local-capability-gate.md
deleted file mode 100644
index d18aad7734..0000000000
--- a/.changeset/install-local-capability-gate.md
+++ /dev/null
@@ -1,101 +0,0 @@
----
-"@objectstack/cloud-connection": minor
----
-
-fix(cloud-connection): the four mutating `install-local` routes require the `manage_metadata` capability, and the `x-user-id` header fallback is gone (#8976)
-
-
-
-**BREAKING for any integration that installs, uninstalls, reseeds or purges a
-local marketplace package with a principal holding no authoring capability — and
-for anything that identified itself to these routes with an `x-user-id` header.**
-Landing after the v17.0.0 cut, so it ships as `minor` under the lockstep
-launch-window convention.
-
-`MarketplaceInstallLocalPlugin`'s `requireAuthenticatedUser` asked one question —
-"is there a session?" — and it was the only check on all four mutating routes:
-
-- `POST /api/v1/marketplace/install-local` — accepts an **inline manifest**,
- hot-registers its objects into the shared registry, runs `syncSchemas()`
- against the shared database, writes the install ledger and runs seed data;
-- `DELETE /api/v1/marketplace/install-local/:manifestId`;
-- `POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data`;
-- `POST /api/v1/marketplace/install-local/:manifestId/purge-sample-data`.
-
-It also ended in a fallback that trusted a bare **`x-user-id` request header**,
-commented as being "for cases where auth is disabled (e.g. test stubs)".
-
-**Measured through the composed plugin, to the point the state actually changes**
-— `manifest.register()`, `objectql.syncSchemas()`, the ledger file on disk,
-`SeedLoaderService.load()`, `driver.delete()`. All three principal shapes were
-indistinguishable, and every effect fired for every one of them:
-
-| principal | install | reseed | purge | uninstall |
-|:--|:--|:--|:--|:--|
-| bare `x-user-id` header, **no session** | **200** | **200** | **200** | **200** |
-| authenticated, **no** `manage_metadata` | **200** | **200** | **200** | **200** |
-| authenticated, `manage_metadata` | 200 | 200 | 200 | 200 |
-
-Nothing downstream refused any of it. The first row is the sharper half: with no
-session store consulted first, a caller who could reach the port completed a
-full schema-mutating install and had `installedBy` recorded as a string of their
-own choosing.
-
-**Severity by deployment shape.** Metadata is environment-scoped rather than
-org-scoped, so Layer 0's tenant wall does not reach these writes: on the walled
-multi-org EE shape this is a cross-tenant write channel — any signed-up user of
-any customer organization could mutate the schema every other tenant runs on,
-and `organization_admin` deliberately withholds `manage_metadata` precisely
-because a tenant administrator is not supposed to. It also nullified the
-already-implemented cloud-side ruling that AI `build` be structurally closed on
-that shape: closing the build agent while this route stayed open closed the
-front door and left the loading dock unlocked. On a single-org self-host the
-severity is genuinely lower — every user is one tenant's — but "any employee
-with a login can alter the schema and run seed data" still contradicts the
-operator-action framing, and the header fallback admitted callers with no login
-at all. The measurements above are code-path measurements through a composed
-host, not an exploit demonstrated against a running deployment.
-
-**The fix.** All four routes now resolve identity **and** capability through
-`resolveAuthzContext` — the platform's single authorization resolver
-(`@objectstack/core`) — and demand ADR-0066 D1's `manage_metadata`, the same key
-the `/meta` write doors carry (#6603, and #8919 for the promotion verbs). A
-caller with no resolvable principal gets `401 UNAUTHENTICATED`; an authenticated
-caller without the capability gets `403 FORBIDDEN` naming the capability they
-need. The refusal is issued before any work, so a refused caller cannot probe
-what is installed through a downstream error. Service and operator tokens are
-exempt exactly as elsewhere, with no special case: an API key resolves through
-the same resolver to its owner's real grants.
-
-**The `x-user-id` fallback is removed, not mode-gated.** It carried no mode flag
-to gate it to, and it was the last `x-user-id` trust left in `packages/**`
-source — the two sibling raw-route surfaces that carried the identical line had
-it *removed* in favour of this same resolver rather than restricted
-(`plugin-sharing`'s share-link routes, `service-settings`' settings routes). The
-one first-party caller of these routes, `os package install`, signs in for a
-real better-auth session cookie and never sent the header.
-
-The plugin's mount stays **unconditional** (cloud#1287 moved it out of the
-`marketplaceUrl` ternary so air-gapped boxes stop 404ing). This is authorization
-on the routes, not un-mounting the plugin.
-
-**Anti-drift.** `marketplace-install-local-capability-enumeration.test.ts`
-derives the mutating routes from the plugin's own route table and compares them
-against a declared list, so a new mutating install-local route fails the build
-until it is enumerated and its refusal cases run. Each refusal asserts the
-ADR-0112 envelope (`code` **and** `status`) *and* that no registry, schema,
-ledger, seed or delete effect fired — a gate that answers 403 after
-`syncSchemas()` has run is still the bug.
-
-Two existing suites whose names read as authorization coverage —
-`marketplace-install-local-posture-gate.test.ts` (the ADR-0120 D5e ceremony,
-which the caller satisfies from their own request body) and
-`marketplace-install-local-tenancy-posture.test.ts` (which selects a seeding
-path) — now open with an explicit statement of what they do **not** cover and
-name the file that does, backed by an assertion that the named file exists so
-the correction cannot rot into a wrong answer. Neither test was weakened.
diff --git a/.changeset/install-local-listing-auth-floor.md b/.changeset/install-local-listing-auth-floor.md
deleted file mode 100644
index 31fc5b76a2..0000000000
--- a/.changeset/install-local-listing-auth-floor.md
+++ /dev/null
@@ -1,85 +0,0 @@
----
-"@objectstack/cloud-connection": minor
----
-
-fix(cloud-connection): the `install-local` listing requires an authenticated principal, and narrows `installedBy` / `storageDir` to `manage_metadata` holders (#9011)
-
-
-
-**BREAKING for any consumer that reads this route anonymously — it now answers `401`
-— and for any authenticated non-operator consumer that reads `installedBy` or
-`storageDir` from it.** Landing after the v17.0.0 cut, so it ships as `minor` under the
-lockstep launch-window convention.
-
-`GET /api/v1/marketplace/install-local` — the console's Setup → "Installed Apps" list —
-called **no** identity resolution whatsoever. `handleList`'s first statement read the
-ledger. After #8976 capability-gated the four mutating doors on this surface, this was
-the only anonymous door left on it: not a weaker gate, the absence of one, so any caller
-who could reach the port received `200` and the complete payload.
-
-**What was disclosed.** Per ledger entry: `packageId`, `versionId`, `manifestId`,
-`version`, `installedAt`, `installedBy`, `withSampleData`; once per response: `items`,
-`total`, `storageDir`.
-
-- `installedBy` is a **platform user id**, and the listing enumerates them across every
- install.
-- `storageDir` is an **absolute filesystem path on the host** (#6721 put it on the wire
- deliberately, for a *signed-in* CLI operator who cannot see the remote host's disk).
-- The inventory itself is a version-level software bill of materials for the deployment
- — which packages, at which versions, installed when.
-
-On the walled multi-org EE shape the inventory and the installer identities are
-cross-tenant information, for the same reason #8976's write channel was: metadata is
-environment-scoped, not org-scoped, so Layer 0's tenant wall does not scope this read
-either. Severity is nonetheless lower than #8976's: this is read-only disclosure, not a
-write channel. The measurement is a code-path measurement through a composed host, not
-an exploit demonstrated against a running deployment.
-
-**The fix — authenticated floor plus field narrowing** (maintainer ruling 2026-08-16):
-
-| caller | status | `items` / `total` | `installedBy` | `storageDir` |
-|:--|:--|:--|:--|:--|
-| anonymous | **401 `UNAUTHENTICATED`** | — | — | — |
-| authenticated, **no** `manage_metadata` | 200 | served | **omitted** | **omitted** |
-| authenticated, `manage_metadata` | 200 | served | served | served |
-
-Splitting the payload rather than gating it whole is the point: "which packages are
-installed here" and "who installed them and where they live on this host" are genuinely
-different sensitivities. Demanding `manage_metadata` for the whole read would have
-withdrawn a console page that ships to non-operator users today, and an authenticated
-floor alone would have left the user ids and the host path on the wire for every signed-in
-account.
-
-The two narrowed keys are **omitted, not nulled** — `null` would be a claim about the
-ledger ("installed by nobody") instead of a fact about the caller. The console already
-renders the "installed by" line conditionally and never reads `storageDir`, so a narrowed
-caller sees the same list minus that one line.
-
-Identity is resolved by the **same** `resolveInstallPrincipal` the four mutating doors use
-— `resolveAuthzContext`, the platform's single authorization resolver — not a second
-session read; two auth mechanisms in one file is how the next gap gets created, and this
-file has already produced one. The 401 envelope is extracted into one
-`refuseUnauthenticated` seam shared by all five routes, so a client branching on
-`UNAUTHENTICATED` never has to learn which door it knocked on. The read door inherits
-#8976's removal of the `x-user-id` fallback: a bare header is still anonymous.
-
-**No new capability is minted** (#8919 discipline) — the narrowing reuses
-`manage_metadata`, matching the `/meta` precedent. The plugin's mount stays
-**unconditional** (cloud#1287 moved it out of the `marketplaceUrl` ternary so air-gapped
-boxes stop 404ing); the answer to an unauthorized read is a refusal, never an absent
-route, and the enumeration suite still asserts the GET is mounted.
-
-**Pinned.** `marketplace-install-local-list-posture.test.ts` pins all three rows above and
-states, in its own docblock, that it is the file which answers "is the listing gated?" —
-the sibling `capability-enumeration` suite answers that only for the mutating doors and
-deliberately filters the GET out. The non-operator row is pinned in **both** directions
-(the inventory is present *and* the two fields are absent), because asserting only the
-absences would keep passing if that caller were refused outright — the option the ruling
-rejected. The refusal asserts the ADR-0112 envelope (`code` **and** `status`) and that it
-is issued **before** the ledger is read, so a refused caller cannot probe what is installed
-through timing or a storage error.
diff --git a/.changeset/install-local-seed-replayer-registration.md b/.changeset/install-local-seed-replayer-registration.md
deleted file mode 100644
index 21cd9f82f2..0000000000
--- a/.changeset/install-local-seed-replayer-registration.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-'@objectstack/cloud-connection': patch
----
-
-Marketplace install-local now registers the per-organization seed replayer alongside its dataset merge, so organizations founded after an install are no longer empty.
-
-Installing a package merged its `data` blocks onto the kernel's shared `seed-datasets` service but never registered the `seed-replayer` service that consumes them. That replayer is registered in `AppPlugin`'s seeder path, so a host runtime declaring no seed data of its own — `objects: []`, no `data`, which is exactly the shape a marketplace install targets — ended up with datasets present and no replayer. On a walled (`isolated` / `group`) deployment the org-scoping middleware then found the datasets, found no replayer, and did nothing: every organization founded after the install received zero rows of the installed app, while the installer's own organization looked correct because it had been seeded inline at install time.
-
-`applySideEffects` now calls the runtime's `registerSeedReplayerOnce` next to the merge, on both the install and the rehydrate path. Registration is register-once by construction, so a host that already has a replayer keeps it and is unaffected; the incumbent re-reads the same shared list and replays the newly installed datasets too.
diff --git a/.changeset/invalid-filter-target-field-provenance.md b/.changeset/invalid-filter-target-field-provenance.md
deleted file mode 100644
index c199c1d014..0000000000
--- a/.changeset/invalid-filter-target-field-provenance.md
+++ /dev/null
@@ -1,63 +0,0 @@
----
-"@objectstack/driver-sql": patch
-"@objectstack/driver-turso": patch
----
-
-fix(drivers): withhold the target field from a policy-authored `INVALID_FILTER` refusal (#8197)
-
-`#7929`/B stopped `driver-sql` echoing the operands of a cross-field
-`{ $field }` refusal, and `#8220` gave that withhold a spec-declared provenance
-mark so an author-written predicate gets its diagnostic back. Neither reached
-the rest of the `INVALID_FILTER` family: five other refusals still named the
-refused constraint's own **target column** to every caller.
-
-That column is not always the caller's. The security middleware ANDs an
-administrator's compiled CEL rule into `opCtx.ast.where`, and on such a
-predicate the target is as administrator-authored as the referent `#7929`
-already withholds — the argument that ruling accepted, one step out. The most
-reachable case is a permission rule over a `multiple: true` field, which lowers
-to a membership test on a JSON-stored column and is refused by `#7398`'s gate
-while naming the column the administrator wrote.
-
-Measured on a real `SqlDriver` (better-sqlite3, `:memory:`) through
-`driver.find`, all five answered `INVALID_FILTER` / 400 naming the target, and
-the author-marked spelling was byte-identical to the unmarked one — the mark
-reached these sites but was never consulted, because none of these builders
-passed through the withheld-refusal carrier.
-
-They now do. The five join the seam `#8220` already owns, with its fail
-direction unchanged:
-
-- the JSON-column operator gate (`#7398`),
-- the zero-operator field constraint (`#5240`),
-- the unbindable comparand (`#5041`) — which also answers a **malformed**
- `{ $field }`, one whose referent is not a string and so never reaches the
- cross-field arm,
-- the `$between` arity refusal,
-
-plus `driver-turso`'s copied `RemoteTransport.uncompilableComparand`, so one
-deployment does not disclose differently depending on its connection mode.
-`driver-sqlite-wasm` inherits `SqlDriver`'s compiler and needed no source
-change.
-
-**Who sees what.** A subtree positively marked `'author'` by a read-scope merge
-boundary keeps the whole diagnostic, target column included. Everything else —
-`'policy'`, unmarked, and ambiguous — receives the refusal's identity
-(`INVALID_FILTER` / 400), which class fired, and the capability statement and
-repair prescription with placeholder names; the naming half goes to the server
-log. Unmarked withholds by design: the mark is permission to reveal, never a
-requirement to prove secrecy, and any design where a missing mark lands on the
-disclosing branch re-opens `#7929`.
-
-**The accepted cost, stated rather than hidden.** The author-vouch surface is
-two call sites, and `plugin-security`'s is conditional on `ast.where` still
-being the caller's verbatim object — which fails once `plugin-sharing` has
-composed (`#8430`). Until that lands, an author on an object with active
-sharing rules loses the target-field name from these messages. That is
-fail-closed, and it is the price of the ruling rather than a defect.
-
-Redaction takes everything derived from the predicate — the target field, the
-operator, the comparand preview, the filter path — for the reason `#7929` gave
-when it withheld both operands rather than one: a comparand preview is the
-administrator's literal just as surely as a column name is, and half a
-redaction is none.
diff --git a/.changeset/ja-jp-position-rename-damage.md b/.changeset/ja-jp-position-rename-damage.md
deleted file mode 100644
index 20bd8e93cb..0000000000
--- a/.changeset/ja-jp-position-rename-damage.md
+++ /dev/null
@@ -1,21 +0,0 @@
----
-"@objectstack/plugin-sharing": patch
----
-
-Repair the ADR-0090 `sys_role` → `sys_position` rename in the ja-JP object
-translation bundle, and extend the mechanical guard to cover it.
-
-`sys_record_share.fields.recipient_id.help` still read "...ユーザー/グループ/ロールの
-ID" — naming the pre-rename `role` concept — while the same bundle already
-rendered the renamed concept correctly, twice, as `ポジション`
-(`recipient_type.options.position` on both sharing objects), and the English
-source for this exact leaf says `position`. Japanese-facing admins saw the
-stale word in the Setup field-help tooltip for Record Share's `Recipient` field.
-
-`recipient-vocabulary-consistency.test.ts` (added when the es-ES half of this
-same rename damage was repaired) now asserts a ja-JP stale-term rule alongside
-the existing es-ES one, generalised into one per-locale table so a future
-locale's rule is one entry, not a parallel `describe` block. The ja-JP pattern
-excludes `ロールアップ` (rollup) and `ロールバック` (rollback) by lookahead rather
-than `\b`, which does not bound katakana in JS regex (`\w` is ASCII-only) and
-would otherwise match nothing at all.
diff --git a/.changeset/last-admin-standing-keys-gate.md b/.changeset/last-admin-standing-keys-gate.md
deleted file mode 100644
index 06108bb469..0000000000
--- a/.changeset/last-admin-standing-keys-gate.md
+++ /dev/null
@@ -1,59 +0,0 @@
----
-"@objectstack/core": minor
-"@objectstack/plugin-auth": minor
----
-
-feat(security): bind the break-glass standing-key lists to what the authz resolver actually reads — the correspondence stops being prose (#8734)
-
-`plugin-auth`'s last-administrator guard (ADR-0024 D5.2) decides whether a
-pending write can empty the administrator population by testing the payload
-against three standing-key lists (`MEMBER_STANDING_KEYS`,
-`GRANT_STANDING_KEYS`, `PERMISSION_SET_STANDING_KEYS`). A payload touching none
-of them is skipped without any reads — so a column `resolveAuthzContext` starts
-reading that a list omits is a write class the guard **silently stops judging**,
-on the one path whose failure mode is an installation-wide administrator lockout
-with no in-product recovery.
-
-Nothing bound the two together. The correspondence lived in a comment, and it
-had already gone false once: #6084 wrote — naming `active` explicitly — that
-everything a permission-set write touches other than `name` is invisible to "who
-is an administrator". That was true when written; #8613 made `active` a
-resolution-time predicate and the sentence became false. Nothing mechanical
-would have caught it, because the guard's own tests stay green precisely when
-the guard is never consulted.
-
-**The mechanism is two links, and the first one is a measurement.**
-
-- `@objectstack/core` now exports `ADMIN_STANDING_SURFACE` — declared beside the
- resolver, listing every table the administrator-derivation path reads, each
- classified `derives` or `reads-only` with its reason, and for the deriving
- tables every column read. It is asserted **equal** to what the real
- `resolveAuthzContext` reads, observed at runtime through a recording engine
- that records every property access and every `where` key per table. Observation
- rather than source extraction because the reads that matter have moved into
- helpers: `active` is read by `isRowActive(row)` and the ADR-0091 window bounds
- by `isGrantActive(row, now)`, neither named at the resolver's own call site —
- the exact shape #8613 had.
-
-- `@objectstack/plugin-auth` now exports its standing-key lists plus
- `STANDING_KEYS_BY_TABLE` and `STANDING_KEY_EXCLUSIONS`, and a gate requires
- every column of that measured surface to have an answer: it is standing-bearing
- (in a list) or it is excluded with the reason it cannot empty the administrator
- population. There is no third state — the third state is what `active` was
- between #6084 and #8613.
-
-So a resolver change that starts reading a new column fails at the first link
-until the declaration is updated, and at the second until the guard has an
-explicit answer for it. Landing #8613 green would have required writing down that
-deactivating `admin_full_access` cannot empty the administrator population —
-which is false, and which is what the old comment asserted by accident.
-
-**No guard behaviour changes.** Every list keeps exactly the values it had; the
-gate is one-directional by construction (it can only ever demand that the guard
-judges *more*), because the other direction would put pressure on a break-glass
-guard to fire less often.
-
-The table-level half is covered too: a resolver that started deriving
-administrator standing from a **new** table is invisible to any column-set
-comparison, since the table is absent from both sides — so the surface enumerates
-every table the path reads, and an unclassified one fails.
diff --git a/.changeset/layered-interface-member.md b/.changeset/layered-interface-member.md
deleted file mode 100644
index efcc902ddc..0000000000
--- a/.changeset/layered-interface-member.md
+++ /dev/null
@@ -1,8 +0,0 @@
----
-"@objectstack/spec": minor
-"@objectstack/metadata-protocol": patch
----
-
-Declare `MetadataProtocol.getMetaItemLayered` — the layered three-way diagnostic read (`GET /api/v1/meta/:type/:name/layers`) now appears on the protocol interface, typed against the already-declared `GetMetaItemLayeredRequestSchema` / `GetMetaItemLayeredResponseSchema`, so callers no longer reach the verb through `any`. Declared optional like its `getMetaItemCached` / `deleteMetaItem` siblings: a declared-surface catch-up to a shipped verb, not a new capability.
-
-In `@objectstack/metadata-protocol`, the implementation's inline return-type annotation for `getMetaItemLayered` drops its dead `'overlay'` arm on `lockSource` and annotates with `MetadataLockSource` directly — the only producer feeding that field on the layered read path is `resolveLockState`, whose return is already typed `MetadataLockSource | undefined` (`'artifact' | 'package' | 'env-forced'`); the `'overlay'` literal in the file belongs to `getEffectiveLock`, a write/delete-door helper that never feeds this response. Type-level change only; no runtime behaviour or wire vocabulary changes.
diff --git a/.changeset/legacy-unique-guard-attribution.md b/.changeset/legacy-unique-guard-attribution.md
deleted file mode 100644
index e6c1a3e80d..0000000000
--- a/.changeset/legacy-unique-guard-attribution.md
+++ /dev/null
@@ -1,51 +0,0 @@
----
-"@objectstack/driver-sql": patch
----
-
-test(driver-sql): attribute each `legacyUniqueReplacements` guard to exactly one case (#8557)
-
-**`patch`, and deliberately not `none`.** This adds no runtime code and changes
-no behaviour — every assertion is green on `main` before the change. The bump is
-the floor rather than a skipped changeset because the file it protects is
-release-relevant: what lands is the pin that makes a future single-guard
-deletion visible, and the release notes for the version that first carries it
-are the place a maintainer looks to learn the pin exists. A `minor` would claim
-a capability; `none` would leave the protection undocumented at the only moment
-anyone reads for it.
-
-The declared-index replacement arm's guards were **individually unpinned**:
-measured on #8468, deleting the ADR-0120 S6 name-identity guard, or admitting a
-declared bare `unique: true` through the scope filter, left the entire suite
-green — including the two tests whose names say they cover exactly those cases.
-The protection was real but collective, so no test attributed it to a line, and
-a refactor could remove any single guard and be told nothing.
-
-`schema-drift.legacy-unique-guard-attribution.test.ts` adds that attribution.
-The existing object-level suites are untouched — they are broader than any one
-guard, which is why they could not do this job.
-
-- **Nine guards are individually attributable.** One input per guard,
- constructed so only that guard can reject it, each paired with a **twin** —
- the same input with the single property that guard reads changed, which must
- produce exactly one replacement. The twin is the reachability witness: without
- it a case would still pass while some earlier guard swallowed the input, which
- is the failure mode being fixed, one level up. Measured: deleting any one of
- the nine turns **exactly one** test red, and its name says which line went.
-- **Five guards cannot be attributed at all**, because another guard rejects a
- superset of their inputs — deleting one is behaviour-preserving for every
- possible argument, so a test claiming to pin it would be lying. For those,
- what is pinned is the **fact the domination rests on**, so the day it breaks
- and the guard becomes load-bearing alone, something goes red.
-
-Behind the dominated S6 guard are the hand-written organization composites on
-`sys_team`, `sys_business_unit` and `sys_member` — three shipped platform
-objects on a spelling valid indefinitely. Those composites are now pinned
-directly, in both the shipped bare-`true` spelling and the respelled
-`'organization'` form.
-
-The bare-spelling case is the test-side half of a pair whose first half already
-shipped: #8463 (PR #8512) put the same divergence into prose on
-`isOrganizationScopedUnique`'s JSDoc, in this same file, with no test attributing
-it. Routing the declared branch through the field predicate remains the rejected
-option 1 of #8323 (maintainer ruling 2026-08-13), and is now refused by a test
-rather than only by a comment.
diff --git a/.changeset/lifecycle-governance-probe-8906.md b/.changeset/lifecycle-governance-probe-8906.md
deleted file mode 100644
index 9fc4989b06..0000000000
--- a/.changeset/lifecycle-governance-probe-8906.md
+++ /dev/null
@@ -1,19 +0,0 @@
----
-'@objectstack/objectql': patch
----
-
-lifecycle: a failed governance row-count probe is no longer indistinguishable from a quiet object
-
-`LifecycleService.checkGovernance()` probed each declared object's row count and swallowed
-every failure with a bare `catch { continue }`. A driver outage therefore read exactly like
-an object with nothing to alert on: no `quota-exceeded`, no `growth`, nothing logged, and
-nothing in the sweep report — and because the failed object also dropped out of the count
-map that becomes the next sweep's baseline, the next sweep could not alert on growth for it
-either.
-
-The probe now discriminates by error type through the shared `isMissingTableError`
-predicate. An unprovisioned table is truthful emptiness and stays silent; every other
-failure is reported per object in the sweep report's existing `errors` list and logged at
-`warn`, both naming the lost growth baseline. No new report field, no new error code, and
-the sweep is still isolated — one object's failed probe never costs the others their
-governance.
diff --git a/.changeset/list-comparand-shape-door.md b/.changeset/list-comparand-shape-door.md
deleted file mode 100644
index 21d3eab330..0000000000
--- a/.changeset/list-comparand-shape-door.md
+++ /dev/null
@@ -1,53 +0,0 @@
----
-"@objectstack/spec": patch
-"@objectstack/objectql": patch
----
-
-fix(spec): enforce the list-comparand rule at the shared compile face, so a scalar `in`/`nin` no longer reaches a driver (#9228)
-
-
-
-`FieldOperatorsSchema` has always declared `$in` / `$nin` as `z.array(z.any())`
-and `$between` as `z.tuple([min, max])`, and #5869 / PR #6209 built the gate that
-enforces it — but only at `@objectstack/objectql`'s lowering seam. That covers
-every query reaching a driver **through the engine** and nothing else. A caller
-that lowers a filter with `parseFilterAST` and calls a driver directly — an
-embedder, and this repo's own driver conformance suites — met no gate at all:
-`parseFilterAST([['name', 'notin', 'alpha']])` returned `{ name: { $nin:
-'alpha' } }`, a shape the contract forbids, and handed it over.
-
-That path was carried by mingo's own coercion of a non-array `$in`/`$nin`
-operand. mingo 7.2.3 removed the coercion, so from 7.2.4 on the same input
-escapes as an unhandled third-party `TypeError: b.filter is not a function` —
-no `code`, no `status`, no field name — straight to the caller. It is the sole
-failure blocking the `mingo` 7.2.2 -> 7.2.4 bump.
-
-**Fixed at the shared face, with exactly one implementation.** The rule now
-lives in `@objectstack/spec`'s `data/filter-comparand-shape.ts`, the same place
-the comparand-TYPE door (#7872 / PR #8234) was promoted to for the same reason
-("enforced once at the shared compile face for all five drivers"), and
-`parseFilterAST` runs it on everything it returns — shape first, then type, the
-order the engine's own seam already applied. `@objectstack/objectql`'s
-`assertListComparandShapes` is now a delegating wrapper whose only remaining job
-is the engine's `find('deal'): ` caller prefix; no driver was patched (both
-driver families are under the #5499 investment freeze).
-
-**The accept/reject delta is narrow and one-directional.** Newly refused, with
-the ADR-0112 `INVALID_FILTER` / 400 envelope: a non-array `$in` / `$nin`
-comparand and a non-`[min, max]` `$between` comparand, reaching a driver via a
-direct `parseFilterAST` call. Nothing else changes — every filter the engine
-accepted still lowers byte-identically, `$in: []` / `$nin: []` remain legitimate
-declared predicates, list MEMBER types stay unjudged here, and a field spec with
-no `$` key is still not descended into. The same inputs were already refused
-with the same envelope on every engine verb and at the REST ingress, so no
-authored metadata in the repo or in `objectui` produces a shape that newly
-fails: a survey of `examples/**`, `content/docs/**`, fixtures, seeded platform
-objects and objectui's view definitions found every membership rule already
-carrying an array.
-
-`parseFilterAST` gains an optional second argument, `context` — the caller
-prefix both doors in `@objectstack/spec` already take. It is additive and
-defaulted; existing calls are unaffected.
diff --git a/.changeset/list-diagnosed-consumer-sweep.md b/.changeset/list-diagnosed-consumer-sweep.md
deleted file mode 100644
index 5dbd30920d..0000000000
--- a/.changeset/list-diagnosed-consumer-sweep.md
+++ /dev/null
@@ -1,56 +0,0 @@
----
-"@objectstack/service-datasource": minor
-"@objectstack/runtime": minor
-"@objectstack/mcp": minor
----
-
-fix(runtime,mcp,service-datasource): the #6504 consumer sweep — three list consumers stop making claims a known-partial read cannot support (#6504)
-
-
-
-`IMetadataService.listDiagnosed?(type)` (PR #7721) lets a plural read say whether
-its answer can be trusted as complete. This is the consumer half: the callers
-that were restating a possibly-short listing as a fact about the environment.
-
-Each consumer was qualified individually, per PR #6051's discipline, and most
-were left alone — a caller publishing a snapshot with no count has nothing to
-mis-state. Three make a claim, and each now withholds exactly that claim while
-still serving everything it could read:
-
-- **`removeDatasource` no longer deletes on a bound-object count it could not
- take completely.** The guard `if (bound > 0) throw` is the only thing standing
- in front of an irreversible delete that also unbinds the datasource's secret,
- and its input is derived from the metadata service's object listing. During a
- loader outage that listing goes silently short, and the worst value is the
- benign one: `0` reads exactly like "nothing is bound", so the guard OPENED.
- It now refuses with `SERVICE_UNAVAILABLE` / 503 — a dependency outage the
- operator can retry, not a client error — and the record, its credential and
- its pool all survive.
-- **The MCP `list_objects` tool stops publishing `totalCount` on a known-partial
- listing.** This is the same claim PR #7721 removed from the
- `objectstack://objects` resource, on the other MCP primitive: same payload
- shape, different door, never covered. A degraded read now serves the same
- objects with `totalCount` **absent** and `partial` / `returnedCount` /
- `warning` plus the 503 envelope in its place, so a client reading the total
- gets `undefined` rather than a believable wrong integer. Both bridges
- implement it — stdio (`@objectstack/mcp`) and HTTP (`@objectstack/runtime`) —
- because a completeness claim must not depend on which transport a client
- connected over.
-- **The ADR-0015 §5.2 boot gate stops announcing an all-clear over a sweep it
- could not complete.** It validated whatever `listObjects()` returned and then
- logged *all federated objects match their remote schema*, with a count.
- Federated objects behind an unreadable loader were never validated, so
- `onMismatch: 'fail'` could not have fired for them. The gate now warns that
- the swept set was incomplete and names what it did validate. ⛔ It does **not**
- abort boot on a degraded metadata read: turning a transient outage into a
- refusal to start would be a new failure mode bought with a diagnosis fix.
-
-Every new member is optional in the same way `listDiagnosed` itself is: a host
-whose metadata service predates the verdict behaves exactly as it did before,
-and a service without it reports nothing degraded — precisely what it could
-express.
diff --git a/.changeset/lock-gate-canonical-type-key.md b/.changeset/lock-gate-canonical-type-key.md
deleted file mode 100644
index 3b3fa98ddd..0000000000
--- a/.changeset/lock-gate-canonical-type-key.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@objectstack/metadata-protocol': patch
----
-
-Fold the metadata lock gate's type key at its producer, so an ADR-0010 `_lock` can no longer be addressed around by spelling.
-
-`getEffectiveLock` handed `type` to its two limbs verbatim, and the limbs did not read it the same way: the artifact limb folded (`lookupArtifactItem` resolves the singular and retries the raw spelling), while the overlay limb queried `sys_metadata` with the raw `type`. Since `SysMetadataRepository` stores rows under the canonical spelling with no at-rest fallback, a non-canonical `type` missed the stored active row and the gate fell through to `lock: 'none'` — not a neutral value but the verdict "the author declared no protection", which `evaluateLockForWrite` / `evaluateLockForDelete` turn into "allow".
-
-`getEffectiveLock` now folds once with `canonicalMetaType` and uses that one key for both limbs. Nothing changes for any caller reachable today — `saveMetaItem`, `deleteMetaItem`, `rollbackMetaItem`, `publishMetaItem` and `publishPackageDrafts` all fold their own request first, which was measured rather than assumed. What changes is the failure mode of a future caller that does *not* fold: the lock is now found, and the write is refused instead of silently admitted.
-
-The fold uses the URL map rather than the manifest-collection map on purpose — the latter omits `field`, `seed`, `external_catalog` and `translation`, i.e. it would canonicalize the types that never needed it and leave the four that do.
diff --git a/.changeset/lucky-pugs-repeat.md b/.changeset/lucky-pugs-repeat.md
deleted file mode 100644
index 3217487edf..0000000000
--- a/.changeset/lucky-pugs-repeat.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@objectstack/service-analytics': patch
----
-
-Source the comparand-type allow-list and the accepted-set refusal sentence from the shared `@objectstack/spec/data` door instead of re-spelling them locally.
-
-`comparand-shape.ts`'s `isBindableComparand` / `isRenderableTextComparand` spelled the same six accepted comparand types (`string | number | bigint | boolean | null | Date`) that `isAcceptedFilterComparand` single-sources for the SQL driver family, and two refusal messages hand-copied the accepted-set sentence. Both predicates now delegate the type membership to the door and quote `ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE`, matching how `driver-sql` and `driver-turso` consume it.
-
-No comparand is accepted or refused differently: the local copies already agreed with the door, and the full accept/refuse matrix is pinned end to end at both analytics filter doors, in three comparand positions each, measured before the change and re-run unchanged after it.
-
-One user-visible wording correction falls out of removing the copy: the hand-copied sentence omitted `bigint`, a type both predicates have always accepted and both doors have always compiled, so a refusal message under-described the values it accepts. The message now names the full set. The package-local extras — a binary bindable, and the `undefined` arm both doors already refuse upstream — are unchanged and recorded at their use sites.
diff --git a/.changeset/lucky-pugs-shave.md b/.changeset/lucky-pugs-shave.md
deleted file mode 100644
index cd40aa9277..0000000000
--- a/.changeset/lucky-pugs-shave.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-'@objectstack/plugin-auth': patch
----
-
-Platform admins can ban and unban users again.
-
-`POST /api/v1/auth/admin/ban-user` and `POST /api/v1/auth/admin/unban-user` are
-now served by ObjectStack with the ADR-0068 platform-admin gate instead of
-better-auth's `admin` plugin, which authorizes on the legacy
-`user.role === 'admin'` scalar that ADR-0068 D2 stopped synthesizing. On any
-deployment with the admin plugin on (SCIM forces it, ADR-0071) the `sys_user`
-Ban / Unban actions returned `403 YOU_ARE_NOT_ALLOWED_TO_BAN_USERS` for every
-platform admin; they now succeed, and refuse a plain member with
-`403 PERMISSION_DENIED` and an anonymous caller with `401 UNAUTHENTICATED`.
-
-The break-glass guard that refuses to ban the last local-password login is
-unchanged and still applies.
diff --git a/.changeset/mcp-http-bridge-merged-skill-read.md b/.changeset/mcp-http-bridge-merged-skill-read.md
deleted file mode 100644
index 06e69e1d30..0000000000
--- a/.changeset/mcp-http-bridge-merged-skill-read.md
+++ /dev/null
@@ -1,55 +0,0 @@
----
-"@objectstack/runtime": patch
----
-
-fix(runtime): the HTTP MCP prompt bridge reads the merged skill listing, so a runtime meta PUT finally reaches `/api/v1/mcp` (#8726)
-
-
-
-`PUT /api/v1/meta/skill/{name}` with `{active:true}` returned 200 and the flip
-was **not** reflected over MCP prompts. This is the second of the two skill
-reads behind that symptom, and the one #8328's own three-step reproduction
-actually runs through.
-
-The two surfaces read different layers:
-
-- **stdio** (long-lived server, `packages/mcp` → `bridgePrompts`) — fixed by
- PR #8724.
-- **HTTP** `/api/v1/mcp`, built **per request** by `packages/runtime`
- (`domains/mcp.ts` → `buildMcpBridge.listSkills`) — this change. It read
- `metadataService.list('skill')`, the registry/loader listing, one layer
- **below** where any `sys_metadata` overlay merging happens. So the overlay row
- the PUT wrote was never seen, while `GET /api/v1/meta/skill` served it
- correctly from the merged read: two surfaces, one skill name, two answers.
-
-The read now goes through the protocol layer's `getMetaItems`, per the
-maintainer's ruling on #8328 (2026-08-13, option 3) — and ⛔ **not** by pushing
-the overlay merge down into `MetadataService.list()` for every consumer, which
-is a wider contract change archived unscheduled as #8722.
-
-**Resolved per request, on the same per-environment seam `getMeta()` already
-uses** — never captured once at boot, which on a multi-tenant host would serve
-one environment's overlay rows to every other one. Pinned by two
-multi-environment tests.
-
-**⛔ No fallback to the un-merged listing when the merged read throws.** That
-would answer registry rows in the shape of merged ones — this exact defect,
-restored silently at the moment the overlay store is unreadable, which is
-precisely when an overlay is most likely to be the thing being missed. The
-throw travels to the MCP client instead. Structural absence is treated as the
-different thing it is: a host assembled without the metadata protocol has no
-merged read to offer, so it keeps the registry listing unchanged, including the
-load-bearing `?? []` for a host with no metadata service at all.
-
-**#6504's completeness verdict is added here rather than preserved** — unlike
-the stdio bridge, this read never had a diagnosed wrapper, so a known-partial
-skill surface presented as a complete one. The verdict is asked of
-`IMetadataService.listDiagnosed` directly rather than taken from the merged
-read, because `getMetaItems` swallows a MetadataService read failure into its
-own `catch` and reports a merged list either way. It is reported at `warn`
-(functional degradation: the prompt surface is visibly smaller than the
-environment declares), and a verdict probe that itself fails is reported as
-"could not be determined" rather than failing a read whose items succeeded.
diff --git a/.changeset/mcp-prompt-bridge-merged-skill-read.md b/.changeset/mcp-prompt-bridge-merged-skill-read.md
deleted file mode 100644
index 11d587d1cc..0000000000
--- a/.changeset/mcp-prompt-bridge-merged-skill-read.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-"@objectstack/mcp": patch
----
-
-fix(mcp): the skill prompt bridge reads the protocol's merged metadata listing, so a runtime `PUT /api/v1/meta/skill/` reaches MCP prompts (#8328)
-
-The bridge read `IMetadataService.list('skill')` — one layer below where the
-`sys_metadata` overlay merge happens — so an override returned 200 and never
-reached the prompt surface while `GET /api/v1/meta/skill` served it. The
-long-lived (stdio) server's bridge now takes its items from the protocol's
-`getMetaItems` when the host can supply it, and keeps the #6504 completeness
-verdict by asking `listDiagnosed` for it alongside. A host assembled without the
-metadata protocol reads exactly as before, and a merged read that throws does not
-fall back to the un-merged listing.
diff --git a/.changeset/mcp-readme-shipped-surface.md b/.changeset/mcp-readme-shipped-surface.md
deleted file mode 100644
index 4819ea0198..0000000000
--- a/.changeset/mcp-readme-shipped-surface.md
+++ /dev/null
@@ -1,68 +0,0 @@
----
-"@objectstack/mcp": patch
----
-
-docs(mcp): rewrite the published README to the shipped host-extension surface (#9579)
-
-`packages/mcp/README.md` is in the package's `files` array with `private` unset,
-so it is the page npm renders. It told the reader to extend the server
-imperatively at six call sites:
-
-```ts
-kernel.getService('mcp').registerTool(calculateRevenueTool);
-kernel.getService('mcp').registerResource({ … });
-kernel.getService('mcp').registerPrompt({ … });
-```
-
-`MCPServerRuntime` has never had any of those members. Measured against the
-built `dist/index.d.ts`, a consumer who copies those lines gets three
-`TS2339 Property … does not exist on type 'MCPServerRuntime'`. The receiver is a
-local variable, so `check:published-readme-exports` is structurally blind to
-them — both of its halves key on a name the fence *imported*, and this one is
-neither imported nor a bare identifier.
-
-Ruled 2026-08-18: **document the shipped surface; do not grow the API to match
-the docs.** So the imperative narrative is gone and the page now documents what
-actually ships — the bridge methods (`bridgeTools`, `bridgeDataTools`,
-`bridgeResources`, `bridgePrompts`), `handleHttpRequest` / `renderSkill`, and the
-exported `registerObjectTools` / `registerActionTools` / `registerSkillPrompts`
-helpers driving an `McpServer`. Every row is probed against the built type entry
-the `exports` map resolves, and the page's one host-extension example compiles
-clean against it.
-
-Neighbouring fabrications the audit turned up, all corrected in the same pass —
-each of them was reachable only through prose or an unimported receiver, which is
-why nothing had read them:
-
-- **A tool family that does not exist.** The page listed
- `objectstack_find` / `objectstack_findOne` / `objectstack_create` /
- `objectstack_update` / `objectstack_delete` / `objectstack_describeObject` /
- `objectstack_listObjects` / `objectstack_listFields` as "auto-registered". No
- such tool name occurs anywhere in the repo. The real names are the
- `list_objects` … `run_action` set the page listed separately, one section down.
-- **`aggregate_records` was missing** from the list that *was* correct, along
- with the fact that it registers only when the bridge implements `aggregate`.
-- **Resource URIs were wrong in both directions.** The page taught
- `objectstack://objects/{name}/records` (no such resource) and
- `objectstack://objects/{name}/{id}` (real shape is
- `…/{name}/records/{id}`), and omitted `objectstack://objects` and
- `objectstack://metadata/types` entirely.
-- **The advertised capability block was invented.** It claimed
- `tools.listChanged`, `resources.subscribe`, `resources.listChanged`,
- `prompts.listChanged` and `experimental.streaming`. The server hand-declares
- only `logging`; everything else is *derived* from what was actually registered,
- which is the ADR-0076 D12 contract the README was contradicting. The
- "Streaming Support" feature bullet and the streaming-resource example went with
- it — neither names anything that ships.
-- **The stdio transport could not be started by following the page.** Neither
- `OS_MCP_STDIO_ENABLED` nor `OS_MCP_STDIO_API_KEY` was documented, and stdio
- auto-start refuses to boot without the key (ADR-0101, fail-closed). The three
- client config blocks now carry both. The Debugging section also taught
- `OS_MCP_SERVER_ENABLED=true` as the stdio switch, which is the deprecated path
- that logs a warning.
-- **A broken relative link.** `../../spec/src/ai/` resolves above the repo root
- from `packages/mcp/`; the target is `../spec/src/ai/`.
-
-Docs only — no runtime code changed, and no API was added. `registerTool` /
-`registerResource` / `registerPrompt` remain unbuilt by ruling; a future
-imperative API is its own card on measured pull.
diff --git a/.changeset/memory-persistence-placeholder-refused.md b/.changeset/memory-persistence-placeholder-refused.md
deleted file mode 100644
index 855814b500..0000000000
--- a/.changeset/memory-persistence-placeholder-refused.md
+++ /dev/null
@@ -1,55 +0,0 @@
----
-"@objectstack/spec": minor
----
-
-feat(spec): refuse `${…}` placeholder syntax in memory `persistence.path` / `persistence.key` at publish (#8495)
-
-**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
-launch-window convention ships it as `minor`; the migration prescription is
-registered under protocol major 18, where `os migrate meta` users will look).
-
-The #8336 defect one surface over: a `${…}` placeholder written in the memory
-driver's persistence config (e.g. `persistence: { type: 'file', path:
-'${DATA_DIR}/mem.json' }`) is resolved by **nothing** — the driver would create
-and write a literal `./${DATA_DIR}/…` path, or write under the literal
-placeholder-bearing localStorage key, with no error naming the unresolved
-placeholder. #8336's ruling (refuse loudly at authoring time — the value was
-authored under a false belief) applies to these two keys with its reason
-intact: they are config-material like the connection keys, not record data.
-
-**What is refused:** a complete `${…}` span in memory `persistence.path` (file
-persistence and the `auto` override) or `persistence.key` (localStorage and the
-`auto` override) — the same shared judgment (`placeholderFree`) the
-connection-material keys use, so the policy cannot drift per key.
-
-**What stays accepted:** every literal path/key byte-identically, including
-placeholder-looking near-misses (`$VAR`, `{name}`, an unclosed `${`) — and the
-memory driver's `initialData` stays deliberately **unjudged**: it carries
-arbitrary record values, where a literal `${…}` may be legitimate data (the
-mother ruling's deliberate memory-driver exclusion, which reached exactly as
-far as its reason did).
-
-## FROM → TO
-
-```ts
-// before — parsed green; the driver created a literal `./${DATA_DIR}/…` path
-defineDatasource({
- name: 'scratch', driver: 'memory',
- config: { persistence: { type: 'file', path: '${DATA_DIR}/scratch.json' } },
-})
-
-// after — write the literal path (or leave it unset: the shared datasource
-// factory scopes the default destination per datasource)
-defineDatasource({
- name: 'scratch', driver: 'memory',
- config: { persistence: { type: 'file', path: './data/scratch.json' } },
-})
-```
-
-There is deliberately **no automatic rewrite**: the placeholder names a value
-that exists only in the author's intended deployment environment, which a
-source-file transform cannot know. `os migrate meta` surfaces the change as a
-structured TODO (semantic entry `memory-persistence-placeholder-refused`,
-protocol major 18 — this refusal is not part of the v17.0.0 cut).
-
-
diff --git a/.changeset/meta-promotion-capability-gate.md b/.changeset/meta-promotion-capability-gate.md
deleted file mode 100644
index 95d5523306..0000000000
--- a/.changeset/meta-promotion-capability-gate.md
+++ /dev/null
@@ -1,82 +0,0 @@
----
-"@objectstack/rest": minor
----
-
-fix(rest): `POST /meta/:type/:name/publish` and `.../rollback` require the `manage_metadata` capability (#8919)
-
-
-
-**BREAKING for any integration that publishes or rolls back metadata with a
-principal holding no authoring capability.** Landing after the v17.0.0 cut, so
-it ships as `minor` under the lockstep launch-window convention.
-
-`packages/rest` gates four metadata-authoring doors on ADR-0066 D1's
-`manage_metadata` capability — `POST /meta/_migrate-stored`, `PUT /meta/:type/:name`
-(#6603), `PUT /meta/:type/:section/:name` and `DELETE /meta/:type/:name` (#7019).
-The two **promotion** verbs did not, and promotion is what decides which body is
-live: `publishMetaItem` flips the `sys_metadata` row `state: 'draft'` to
-`'active'` (ADR-0027 (E)(5) defines sealing a publish as exactly that flip), and
-`rollbackMetaItem` restores a caller-supplied `toVersion` as the new live row.
-
-**Measured through a composed host, down to the protocol layer, before the fix:**
-
-| principal | publish | rollback |
-|:--|:--|:--|
-| anonymous | 401, protocol not reached | 401, protocol not reached |
-| authenticated, **no** `manage_metadata` | **200, protocol reached** | **200, protocol reached** |
-| authenticated, `manage_metadata` | 200, protocol reached | 200, protocol reached |
-
-So the reachable cohort was every authenticated principal holding no authoring
-capability at all: it could take a draft somebody else authored and make it
-live, or restore any historical version over the live row. Anonymous callers
-were already refused by the `/meta` umbrella (`registerMetadataEndpoints`), so
-what these gates add is precisely the authenticated-but-uncapable cohort.
-
-**`rollback` is the sharper of the two.** The caller supplies `toVersion`, which
-makes it a mechanism for reverting security hardening — a permission set as it
-stood before it was tightened, a validation rule from before it existed, a
-layout from before field-level security. It is also the door with the least
-behind it: publish at least re-runs `assertRuntimeAuthoringRules` on the
-promoted draft (#4463 D1), while rollback runs no content gate at all. Neither
-of those reads the caller in any case — D1 answers "is this metadata valid", not
-"may you press this button" — so nothing downstream was ever doing this job.
-Audit rows are still written either way, so the action remains traceable after
-the fact.
-
-**No legitimate caller loses anything, and that is measured rather than
-assumed.** The Studio designer's save-then-publish loop saves `?mode=draft` and
-then POSTs `/publish`, and its **first** step already demanded
-`manage_metadata` — so every principal that can author a draft already clears
-the new gate. The shipped sets bear this out: `admin_full_access` (the only set
-carrying `studio.access`) carries `manage_metadata` too, while
-`organization_admin` and `member_default` are refused at the save door **today**.
-The only callers the gap benefited were exactly the ones already refused the
-authoring door — able to promote a draft they could not have written.
-
-**Migration — grant `manage_metadata` to any service principal that publishes.**
-An integration that promotes metadata on its own schedule (a CI job sealing a
-release, an AI authoring agent) needs the capability explicitly; there is no
-automatic replacement, deliberately. `isSystem` contexts bypass, as on every
-other capability gate on the platform, so in-process callers are unaffected.
-
-The gate is the sibling doors' four lines verbatim, deliberately not a second
-way of demanding the same capability, and it fires **before** the protocol is
-resolved so 403-vs-501 leaks no kernel capability and nothing is promoted before
-the refusal.
-
-⚠️ **An author/publisher capability split is NOT introduced here.** Separating
-"may write a draft" from "may make it live" is a defensible design, but it needs
-a *different* declared capability and is a product decision; both defensible
-designs require a gate, and the state this fixes was neither.
-
-Ships with an **enumeration pin** rather than two assertions. The defect was not
-that two handlers forgot a gate — it was that the gate was a convention held by
-repetition and nothing else, so the next metadata write door had a one-in-three
-chance of copying an ungated neighbour with no test going red. The new suite
-derives the write doors from the composed server's own route table and compares
-them against a declared list, so a new mutating `/meta` route fails the build
-until it is enumerated and its refusal asserted.
diff --git a/.changeset/meta-read-organization-id-declared.md b/.changeset/meta-read-organization-id-declared.md
deleted file mode 100644
index 54681b918c..0000000000
--- a/.changeset/meta-read-organization-id-declared.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@objectstack/spec": minor
----
-
-Declare `organizationId` on the metadata read request schemas — `GetMetaItemsRequestSchema`, `GetMetaItemRequestSchema` and `GetMetaItemCachedRequestSchema` — matching the member the protocol implementation has accepted and honoured all along (it selects the org partition in the ADR-0005 overlay read order, deciding which tenant's customization rows are served; on the cached read it also enters the ETag). Also declares `GetMetaItemLayeredRequestSchema` (+ `GetMetaItemLayeredRequest`), the request shape of `GET /api/v1/meta/:type/:name/layers`, mirroring the implementation's parameter type member for member alongside the already-declared layered response schema. Accept-set widening catch-up only: no runtime behaviour changes, and requests without `organizationId` remain valid environment-wide reads.
diff --git a/.changeset/meta-read-path-credential-redaction.md b/.changeset/meta-read-path-credential-redaction.md
deleted file mode 100644
index 7e667ff6ed..0000000000
--- a/.changeset/meta-read-path-credential-redaction.md
+++ /dev/null
@@ -1,50 +0,0 @@
----
-"@objectstack/metadata-protocol": patch
----
-
-fix(metadata-protocol): the metadata read path no longer serves stored cleartext credentials (#8154)
-
-`decorateMetadataItem` returned the whole stored body, so a `datasource` row
-written before #8078 closed the write door came back with `config.password` in
-cleartext — and the password embedded in `config.url` alongside it — from
-`GET /api/v1/meta/datasources`, from the single-item read, and from the layered
-read in **both** its `overlay` and `effective` layers. PR #8126 closed the
-datasource-admin door (`GET /api/v1/datasources/:name`); this closes the
-platform door one over. Meta read permission is granted at a far lower bar than
-"may see the production database password", which is what made this reachable.
-
-The fix consumes the per-type redactor registry #8300 landed in
-`@objectstack/spec/kernel` (`getMetadataTypeRedactor`) rather than redacting
-`datasource` specifically: `datasource` is that registry's first consumer, and a
-type-shaped patch here would be the narrow fix that leaves the next
-secret-bearing type exposed. A plugin whose metadata type stores secrets gets
-the same protection by calling `registerMetadataTypeRedactor` — no change here.
-
-Three properties worth knowing, each measured rather than assumed:
-
-- **`_diagnostics` are still computed on the RAW stored body, before
- redaction.** The redacted body is exactly the shape the post-#8078 schema
- accepts, so computing them afterwards flips `valid:false` to `valid:true` on
- precisely the rows that hold a stored credential — which would delete the
- operator's only inventory of what still needs migrating (#8081 item 3). The
- two steps are composed inside one function so no call site can invert an
- ordering it cannot see.
-- **The stored record is never mutated, and the connect path is untouched.**
- Redaction is a serving act; datasource connection and boot-time restore read
- `sys_metadata` directly through the data engine, not through these exits.
-- **The write path carries the credential forward**, and this half is not
- optional: `saveMetaItem` accepts a redacted body and persists the credential
- away, so a read scrub shipped alone would convert today's loud `422` into
- **silent credential deletion** on an ordinary GET → edit → PUT round trip.
- `config.url` makes it unavoidable rather than a masking choice — a
- URL-embedded password is schema-accepted, so dropping it round-trips to
- deletion and masking it round-trips to storing the mask as the literal
- password. Stored material is re-applied only where the incoming body is
- indistinguishable from what the read served; anything the author actually
- wrote wins and is still judged by #8078's write gate on its own merits. This
- also restores the #4326 byte-identical round-trip invariant, which
- read-redaction alone would have broken.
-
-It preserves cleartext already at rest and creates none; moving stored
-credentials into `sys_secret` is #8081 item 3's migration and is deliberately
-not attempted on a write door an author drove.
diff --git a/.changeset/meta-unknown-type-read-refusal.md b/.changeset/meta-unknown-type-read-refusal.md
deleted file mode 100644
index bb419899f4..0000000000
--- a/.changeset/meta-unknown-type-read-refusal.md
+++ /dev/null
@@ -1,65 +0,0 @@
----
-"@objectstack/rest": patch
----
-
-fix(rest): `GET /api/v1/meta/:type` refuses a type name that names nothing, instead of serving it as an empty collection (#9488)
-
-
-
-```
-GET /api/v1/meta/totally_invented_type → 200 {"type":"totally_invented_type","items":[]}
-PUT /api/v1/meta/totally_invented_type/x → 400 "'totally_invented_type' is not a metadata type"
-```
-
-The two doors disagreed about which type names exist. A
-200-with-an-empty-collection is **indistinguishable from "this type exists and
-holds nothing"**, so a typo'd or renamed type name read as an empty surface
-rather than as a mistake — the same trap `GET /meta/app?id=` was
-already filed for, where the answer read to a runner as "the app metadata is
-gone".
-
-The list door now answers **`400` / `INVALID_REQUEST`**, naming the type: the
-same status and the same code the write door has emitted since `PUT /meta//x`
-was closed, so one condition has one answer on both doors. `INVALID_REQUEST` is
-already registered to `@objectstack/rest` in the ADR-0112 `ERROR_CODE_LEDGER`;
-no code is minted. The refusal is thrown rather than hand-built, so its wire
-body is byte-identical to the write door's for the same condition.
-
-**What still answers `200` with an empty collection**, because a type that
-exists and has no items is the legitimate case the defect was indistinguishable
-from — breaking it would be worse than the bug:
-
-- every member of the static spelling contract (`sharing_rule`, `theme`,
- `objects`, `api`, …), whether or not the deployment holds one item of it;
-- the live-only keys an ordinary `registerApp` produces — `data`, `kind`,
- `package`, `policy` — which sit outside the static contract but are
- enumerated by `GET /api/v1/meta/types`;
-- a plugin's own type, which enters the live set as a side effect of
- registering items of it.
-
-That is why the rule is the **union** of the two authorities the platform
-already has — the static predicate the write door consults, and the live
-listing `GET /meta/types` serves — rather than the static predicate alone.
-Refusing on the static half alone would answer `400` for types this same
-service advertises, which is the objection recorded when the write-side verdict
-landed and was deliberately not raised on the read entries then. Neither list
-is restated here; both are read from their producers.
-
-The static verdict runs first and is silent for every accepted spelling, so an
-ordinary list request pays nothing; the live listing is consulted only by a
-request already headed for a refusal. If that listing cannot be read — no
-`getMetaTypes` on the host's protocol, or a rejecting call — the route **fails
-open** and keeps its prior answer: "no such type" is an existence claim, and
-stating it while the authority that would know is unreachable is the mistake
-the write door's own store probe avoids.
-
-**Scope.** The list door only. The compound arity `/meta/lead/views/all_leads`
-carries an *object* name in the `:type` segment, which no static contract can
-enumerate, and is untouched. The single-item doors already refuse
-distinguishably (`404 RESOURCE_NOT_FOUND`, or `501 NOT_IMPLEMENTED` on the
-`/references`, `/layers`, `/history`, `/audit`, `/diff`, `/published` limbs), so
-none of them carried this defect.
diff --git a/.changeset/meta-unrecognised-type-refused.md b/.changeset/meta-unrecognised-type-refused.md
deleted file mode 100644
index b2af06345b..0000000000
--- a/.changeset/meta-unrecognised-type-refused.md
+++ /dev/null
@@ -1,149 +0,0 @@
----
-"@objectstack/spec": minor
-"@objectstack/metadata-protocol": minor
----
-
-fix(metadata): `PUT /meta/:type` refuses a type name the platform does not have, instead of minting a namespace for it (#8421)
-
-
-
-
-**BREAKING** accept-set narrowing on a published HTTP surface, landing after the
-v17.0.0 cut (the lockstep launch-window convention ships it as `minor`). A write
-that answered `200 {"success":true}` now answers `400 INVALID_REQUEST`:
-
-```
-PUT /api/v1/meta/fieldz/showcase_task.title
- before → 200, sys_metadata row persisted with type='fieldz'
- after → 400 INVALID_REQUEST, nothing persisted
-```
-
-`fieldz` — or any typo — was neither a declared metadata type nor a known plural
-spelling of one, so the boundary classified it as PLUGIN-registered, which every
-authorization gate is permissive toward by construction. The row was persisted
-under a type nothing reads and nothing serves, and the caller was told it had
-succeeded. That silence is the real cost: a metadata-type typo, from a human or
-from generated code, produced `success: true` and no indication the type is not
-real.
-
-**Why this is only now safe to refuse.** #7894 closed the sibling case (a plural
-spelling of a type the platform DECLARES) and left this one open on purpose: a
-static predicate cannot tell `fieldz` from a plugin kind, and the live-registry
-alternative was measured to be worse than the defect — the live type set is
-ITEM-POPULATED, so it omits every legitimate kind that has no items yet, which
-is the state each kind is in immediately before its first create. What changed
-is the platform, not the boundary's information: #8586 retired
-`MetadataPluginConfig.additionalTypes` and with it the last channel by which a
-plugin could DECLARE a metadata kind, so an unrecognised name can no longer be a
-declaration this refusal has not heard about (maintainer ruling 2026-08-14).
-
-**What still passes, pinned in both directions.** Every declared type in
-`DEFAULT_METADATA_TYPE_REGISTRY`, in canonical and REST-plural spelling; every
-manifest spelling and the singular each folds to; and the six plugin kinds that
-have no static registry entry at all — `theme`, `webhook`, `connector`,
-`sharing_rule`, `analytics_cube`, `rag_pipeline`. `PUT /meta/theme/dark` on a
-deployment with zero themes is explicitly covered, because that first create is
-exactly what a live-registry check would have broken.
-
-**The refusal is scoped to the door that mints.** Reads still ANSWER: a running
-kernel legitimately holds live type keys the static contract does not — `data`,
-`kind` and `package` all enter the registry during an ordinary `registerApp`,
-and `GET /api/v1/meta/types` lists that live set — so refusing unrecognised
-names on the read path would answer 400 for types the same service advertises.
-`DELETE` is untouched for the mirror-image reason: rows minted under an
-unrecognised type before this change are real, nothing rewrites them on upgrade,
-and refusing their deletion would turn the accumulation this fixes into an
-accumulation nobody can clear.
-
-**…but one published ADVERTISEMENT narrows with it, and that is a second
-behaviour change worth reading on its own.** `GET /api/v1/meta/types` keeps
-listing every live type, and every entry keeps every field — what changes is the
-VALUE of one boolean:
-
-```
-GET /api/v1/meta/types → entries[] where type ∈ {policy, data, package, kind}
- before → allowRuntimeCreate: true
- after → allowRuntimeCreate: false
-```
-
-The listing synthesised `allowRuntimeCreate: true` for every live type with no
-static registry entry, on the same expired premise as the write door: a name the
-registry does not carry might be a kind some plugin declared. It now derives that
-flag from the SAME predicate the mint door enforces, so the two endpoints agree
-by construction instead of via two rules maintained apart. Nothing ever honoured
-a runtime create on those four — they are internal bookkeeping (seed datasets,
-package rows, kind descriptors) — so the advertisement was a promise the platform
-did not keep, which is the same defect this card is about, relocated to the read
-door. Direct precedent: `api` declared `allowRuntimeCreate: true`, the runtime
-never honoured it, and the 2026-08-07 ruling removed the declaration rather than
-converging the read path onto it.
-
-⛔ The six plugin kinds with no registry entry — `theme`, `webhook`, `connector`,
-`sharing_rule`, `analytics_cube`, `rag_pipeline` — are **not** affected: they are
-in the static spelling contract, stay advertised `allowRuntimeCreate: true`, and
-stay mintable. A UI reading this field (Setup → Metadata, the Studio designers)
-therefore loses create affordances on exactly the four types whose creates were
-already refused, and keeps them everywhere else.
-
-**The premise behind both halves is a CURRENT posture, not a closed door.**
-Maintainer ruling, 2026-08-15, verbatim and untranslated:
-暂时不考虑让插件申明新的元数据类型 — plugins do not declare new metadata types
-*for now*. That word is recorded deliberately: plugin-declared kinds were
-considered and deferred, not ruled out. If they are ever wanted, the two sites
-that encode the deferral name it and its date in place —
-`getMetaTypes()`'s synthesis and `isRuntimeCreateAllowed` in
-`@objectstack/metadata-protocol` — so the decision is findable rather than
-re-derived from the code's silence.
-
-**Two shapes reaching the mint door are exempt, and each is a fact about the
-request rather than a claim the caller makes.**
-
-1. *The COMPOUND arity carries an OBJECT name in the `:type` segment.*
- `PUT /api/v1/meta/lead/views/all_leads` is `type='lead'`,
- `name='views/all_leads'` — one operation reaching one save, the shape both
- the runtime dispatcher and the REST route document verbatim. `lead` is an
- object, i.e. runtime data no static contract can enumerate, so a type verdict
- applied there would refuse every object name that is not coincidentally a
- metadata type. The ruling is about metadata TYPE names like `fieldz`.
- ⚠️ Residue, stated rather than hidden: `PUT /meta/fieldz/a/b` is therefore
- still accepted, because at that arity `fieldz` is a claim about an object and
- the only way to check it is the live-registry lookup this card ruled out.
-2. *A namespace that already exists is not being minted.* `duplicatePackage`
- re-saves every row of a package under a new name, taking each type from the
- stored row — measured: a package holding one pre-existing residue row
- answered `{success: false, copiedCount: 0, failedCount: 1}`, i.e. could not
- be duplicated at all. That contradicts the `DELETE` reasoning above, so the
- store (never the request) exempts a type that already has rows. The probe
- runs only once the refusal has already fired, and a store that cannot answer
- refuses — a fresh deployment has no residue to protect.
- `migrate meta --stored` was read as a third victim and measured NOT to be
- one: an unrecognised type has no manifest collection, hence no ADR-0087
- chain, hence no notice, so such a row is reported `canonical` and the mint
- door is never reached.
-
-**What breaks.** A caller creating metadata at runtime, at the simple arity,
-under a type name that is in neither half of the static spelling contract and
-has no rows already. That set is **not** empty in this repo — measured on
-`objectql`, `runtime` and `rest`, three in-tree fixtures minted `trigger` (a kind
-ADR-0088 retired outright), `policy`, and a synthetic `my_plugin_kind`. All three
-are corrected here rather than exempted, and each for its own reason: the
-`trigger` specimens were debt independent of any ruling (a retired kind cannot
-demonstrate a live tier, and they were green only through the hole this card
-closes), `policy` becomes a refusal case of its own, and #7894's control keeps
-its `metaUrlSpellingRefusal` claim while its boundary expectation follows the
-narrowing. An out-of-tree plugin that made its kind live by registering an item
-of it, and then accepted runtime writes to that kind through `/meta`, needs its
-spelling in the contract; there is no declared-kind channel to register one
-through today — that is the trade #8586's retirement made, and the `暂时` above
-is what makes it revisitable.
-
-`@objectstack/spec` gains one export, `unrecognisedMetaTypeRefusal`, alongside
-the #7894 verdict it deliberately does not merge with: one says *you spelled a
-declared type wrongly* and can name the replacement, the other says *there is no
-such type* and never guesses. The residue pin #7894 left behind
-(`metadata-url-spelling.test.ts`, the case that asserted `fieldz` was refused by
-nobody) is **flipped, not deleted**. ⚠️ #7894's positive control keeps its own
-claim intact — `metaUrlSpellingRefusal` still cannot refuse a kind that is a
-misspelling of nothing, which is what makes that control true by construction —
-but the BOUNDARY it drives now refuses six of the twelve names it exercises,
-and that case says so in place rather than leaving it to inference.
diff --git a/.changeset/meta-write-actor-identity-wins.md b/.changeset/meta-write-actor-identity-wins.md
deleted file mode 100644
index 08a3c5cb3f..0000000000
--- a/.changeset/meta-write-actor-identity-wins.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-'@objectstack/rest': minor
----
-
-**Audit attribution change — the recorded actor on `/meta` writes is now the authenticated identity, and `X-Actor` is ignored.** All five `/meta` write sites (save, delete/reset, publish, rollback, compound save) stamp `sys_metadata_audit.actor` and `sys_metadata_history.recorded_by` with the identity the request was actually authorized as. A request that sends `X-Actor` is recorded against its own authenticated caller, not the header's value. Maintainer ruling 2026-08-12 on #7941, re-confirmed 2026-08-15.
-
-Why: the header used to outrank the authenticated identity. That ordering was inert for as long as the other limb produced nothing — `req.user` / `req.userId` are never set on this transport — so nothing depended on it. Fixing that producer (#7749) made the precedence load-bearing for the first time, and what it then meant was that any caller already holding `manage_metadata` could sign somebody else's name to a metadata write: the compliance trail answered "who *claimed* to change this" rather than "who changed this", which is the question #7749 was filed to make answerable. Attribution now cannot drift from authorization, because both read the same `resolveExecCtx` the route's own capability gate reads.
-
-The header limb is **removed rather than reordered**. The ruling permitted keeping it for genuine machine/system callers with no authenticated user, but only if a consumer census showed that shape exists — it does not, so a caller cannot choose the recorded name in any shape, including on the machine-write path where there is no identity for the header to lose to.
-
-Deliberately unchanged:
-
-- **Real impersonation still attributes correctly.** The platform's impersonation is session-level (better-auth admin plugin, `sys_session.impersonated_by`), so `resolveExecCtx` already resolves to the impersonated user and their metadata writes are recorded against them. Nothing in that path went through `X-Actor`.
-- **Machine and anonymous writes.** No resolved principal still means no actor, so the protocol's own `'system'` / `NULL` defaults apply exactly as before — a machine write is never stamped with a real user.
-- **Sending `X-Actor` is not an error.** It is ignored, not rejected; no request that succeeds today starts failing.
-
-Who is affected: any caller that relied on `X-Actor` to attribute a `/meta` write to somebody other than itself. The census over `objectstack` and `objectui` found no such caller — `objectui`'s `MetadataClient` can send the header through an optional `options.actor`, but nothing in that repo ever passes one, leaving that option inert against this server.
diff --git a/.changeset/metadata-422-container-issue-descent.md b/.changeset/metadata-422-container-issue-descent.md
deleted file mode 100644
index 1bb76d300c..0000000000
--- a/.changeset/metadata-422-container-issue-descent.md
+++ /dev/null
@@ -1,60 +0,0 @@
----
-"@objectstack/metadata-protocol": patch
----
-
-fix(metadata): the `422 INVALID_METADATA` envelope descends `invalid_key` / `invalid_element`, so a rejected record key arrives with the rule it broke (#8783)
-
-Zod raises a `z.record` / `z.map` **key** rejection as `invalid_key` and a
-`z.map` **element** rejection as `invalid_element`, and in both cases the
-issue's own `message` is a bare wrapper — `"Invalid key in record"` — with the
-real diagnosis one level down in `issue.issues`. That is structurally the
-`invalid_union` shape #4971 named: the prescription is produced and then
-dropped by a walk that reads only the top level.
-
-Both `packages/spec` walks learned to descend those codes in #5389.
-`zodIssuesToMetadataIssues` — the walk behind `saveMetaItem`'s 422 (#5364) and
-the read path's diagnostics (#5598) — expanded `invalid_union` only, so it
-stopped at the wrapper. Three walks over one `safeParse`, two of them reaching
-the prescription and the Studio-facing one not.
-
-**It was reachable from ordinary authored metadata, not synthetic.**
-`ObjectSchema.fields` is a record whose KEY schema carries the snake_case rule
-(`spec/src/data/object.zod.ts`), and `object` is in the builtin
-`getMetadataTypeSchema` registry. So the commonest authoring mistake on the
-most-authored metadata type — writing `firstName` for a field key, which is
-exactly what an agent coming from JS naming writes — produced:
-
-```
-{ path: 'fields.firstName', code: 'invalid_key', message: 'Invalid key in record' }
-```
-
-The author was told a key was invalid and never told what a valid one looks
-like, so the next move was to guess. The declared message existed and was
-correct; it just did not reach anyone. Now the same save answers:
-
-```
-{ path: 'fields.firstName', code: 'invalid_key', message: 'Invalid key in record' }
-{ path: 'fields.firstName', code: 'invalid_format', message: 'Field names must be lowercase snake_case (e.g., "first_name", …)' }
-```
-
-**Additive, and matched to the walks that already worked** rather than chosen.
-The other two were measured over the card's own repro first: `formatZodIssue`
-prints the wrapper line then the indented detail, and `zodIssuesToFields` emits
-the `invalid_shape` wrapper entry then the detail entry. So the wrapper stays at
-index 0 — it is the only entry naming the slot the client sent, and Studio's
-designer keys on it — and the detail joins it on the same path. No entry that
-shipped before is removed or renumbered.
-
-**Targeted, not a widened walk.** Only the two container codes open the descent;
-an `issues` array hanging off any other code is still ignored, `invalid_union`
-still expands through the unchanged ranking, and the nesting bound now covers
-both descents at the same depth of 3. Container issues are deliberately *not*
-ranked the way union branches are: a union's branches are competing candidates,
-while a container has one inner schema, so every issue it raised is a true
-statement about the value.
-
-The verdict is unchanged in every case — this moves what a refusal *says*, never
-whether it is one. `union-branch-policy.cross-package-parity.test.ts` gains a §5
-comparing the container descent across all three walks; its §1 (the policy is
-not publicly exported from `@objectstack/spec`, so this package must run its own
-copy) is untouched, and no export was added.
diff --git a/.changeset/metadata-fs-external-write-resync.md b/.changeset/metadata-fs-external-write-resync.md
deleted file mode 100644
index 2c4b8e2774..0000000000
--- a/.changeset/metadata-fs-external-write-resync.md
+++ /dev/null
@@ -1,54 +0,0 @@
----
-"@objectstack/metadata-fs": patch
----
-
-fix(metadata-fs): an external write reaches subscribers even when the watcher's single delivery attempt is lost — content-keyed reconciliation behind the poll (#9339)
-
-`FileSystemRepository`'s watcher gave an externally-written file **exactly one**
-chance to be noticed, and losing it was permanent and silent. Under
-`usePolling`, chokidar re-reads a directory only when its stat *strictly*
-advances; an external write advances the type directory's mtime once, so poll
-#2..#N compare an unchanged stat and can never rediscover the file. Measured on
-#9339 with a fault-injection harness: with that single read suppressed, fifteen
-further poll ticks never find the file — a 20s deadline and a 200s deadline buy
-the same one attempt. That is the structural reason behind #7282's empirical
-finding that the event is *"never delivered, not slow"*, and why widening the
-deadline (#7208) and lowering `interval` were both spent before they were tried.
-
-**At least six independent one-shot gates sit on that attempt**, spanning three
-layers — the kernel timestamp (the directory mtime does not strictly advance),
-chokidar's readdir throttle and readdir snapshot, and chokidar's emit gates
-(`_throttle('add')`, a stale `_pendingWrites` entry, the `awaitWriteFinish`
-ENOENT early return). Each produces a byte-identical observable: no event, ever,
-for that path. They are indistinguishable at the point of failure, which is why
-#7282's close — picked from that family — covered one member and reopened.
-
-**The fix does not name a member.** A bounded, content-keyed reconciliation
-sweep runs alongside the watcher and compares what is on disk against `heads`,
-the index that already defines what the repository believes it holds, publishing
-any divergence through the *same* handler the watcher feeds. Its only premise is
-that the bytes on disk stopped matching the index, so it is robust across all six
-by construction — and equally across a seventh nobody has found.
-
-- **Cadence** — one pass over `//*.json` every 2s (twice the poll
- interval), the same walk `start()` already performs once. Sweeps are chained
- rather than intervalled, so they can never overlap or stack behind a slow
- disk; the timer is `unref`ed and is retired by `close()`; and it is armed only
- alongside the watcher, so a `disableWatch` repository pays nothing.
-- **Exactly-once is preserved.** Suppression stays content-keyed (`#7335`): the
- sweep republishes nothing the watcher already delivered, and recognises this
- repository's own `put()` by content rather than by a clock.
-- **Events are indistinguishable from the fast path** — same `op`,
- `parentHash`, `source: 'fs'` and actor, because they are produced by the same
- code. A subscriber cannot be made to care which path noticed.
-- **A recovered path is re-armed** with the watcher through the seam `put()`
- already uses, so a loss upstream of chokidar's `_handleFile` does not leave
- the file dependent on the sweep forever.
-- `put()`'s existing direct registration (#7336) is unchanged, as are
- `usePolling`, `interval`, and `awaitWriteFinish`.
-
-⚠️ **Bound on the claim.** The six gates are *forced fault injections*, not the
-CI mechanism, which was never identified and may be a seventh. What is measured
-is that the fix converts **six of six** forced one-shot gates from permanent
-loss to delivery (3/3 runs each), where all six returned an empty event list
-before it. That is not the same statement as "the flake is fixed".
diff --git a/.changeset/metadata-plugin-additional-types-retired.md b/.changeset/metadata-plugin-additional-types-retired.md
deleted file mode 100644
index 7324bccf10..0000000000
--- a/.changeset/metadata-plugin-additional-types-retired.md
+++ /dev/null
@@ -1,62 +0,0 @@
----
-"@objectstack/spec": minor
----
-
-feat(spec): retire the inert `additionalTypes` key from `MetadataPluginConfig` (#8586, ADR-0049)
-
-**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
-launch-window convention ships it as `minor`; the migration prescription is
-registered under protocol major 18, where `os migrate meta` users will look).
-
-`MetadataPluginConfig.additionalTypes` was declared, authorable, and documented
-on four docs pages as THE way a plugin registers a custom metadata type — and
-read by **nothing**. The only production writer of the manager's type registry
-is `setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)`, called exactly once, and
-it replaces the array outright: measured on the real `MetadataManager`,
-declared count == live count (27 == 27). An author who followed the published
-instructions wrote the key, got no error, and nothing happened — the #4212
-`onInstall` silence trap one level down (maintainer-ruled REMOVE, 2026-08-14).
-
-**What is refused:** an authored `additionalTypes` on `MetadataPluginConfig`
-(inline or via the manifest's `config` embed). The key is a `retiredKey()`
-tombstone — the schema is not `.strict()`, so a plain deletion would have
-silently stripped it — refused at `tsc` (typed `never`) and at the parse
-(`invalid_type` at path `additionalTypes`, message carrying the prescription).
-
-**What stays accepted:** every `MetadataPluginConfig` without the key,
-byte-identically. Runtime behaviour is unchanged: nothing ever read the key,
-so removing it removes no behaviour.
-
-The retirement kit:
-
-- tombstone at the schema (`packages/spec/src/kernel/metadata-plugin.zod.ts`)
-- ADR-0087 registration: retired-key entry
- `kernel/MetadataPluginConfig:additionalTypes` + D3 semantic entry
- `metadata-plugin-additional-types-retired`, both under protocol 18 (no D2
- conversion — a plugin config is not a stack collection member, the
- `kernel/Manifest:loading` precedent)
-- pin tests (`additional-types-retirement.test.ts`)
-- docs corrected: `content/docs/plugins/adding-a-metadata-type.mdx` (four
- sites) now describes how a kind actually enters the live set — as a side
- effect of registering an item of that kind; the generated reference page
- follows the schema
-- the two source comments that asserted the phantom growth path
- (`metadata-manager.ts`, `metadata-protocol/src/protocol.ts`) and the
- `registerMetadataTypeSchema` doc note corrected
-
-## FROM → TO
-
-```ts
-// before — parsed green; the entries were merged into nothing
-const config: MetadataPluginConfig = {
- storage: {},
- additionalTypes: [{ type: 'chart', label: 'Chart', filePatterns: ['**/*.chart.ts'], domain: 'ui' }],
-};
-
-// after — delete the key; register items of the kind instead, and bind its schema
-const config: MetadataPluginConfig = { storage: {} };
-// in the plugin: registerMetadataTypeSchema('chart', ChartSchema) from init(ctx);
-// the kind enters the live set when an item of it is registered.
-```
-
-
diff --git a/.changeset/metadata-plugin-watch-default-false.md b/.changeset/metadata-plugin-watch-default-false.md
deleted file mode 100644
index 2d4d8cb104..0000000000
--- a/.changeset/metadata-plugin-watch-default-false.md
+++ /dev/null
@@ -1,37 +0,0 @@
----
-"@objectstack/metadata": patch
----
-
-fix(metadata): `MetadataPlugin`'s `watch` option defaults to `false`, as its own doc comment documents (#9770)
-
-`MetadataPluginOptions.watch` documents its default as ``Default: `false` (post PR-10e —
-was previously `true`)``, directly above the field. The constructor implemented the
-opposite, and it did so in **two** places, so both entry shapes resolved `true`:
-
-- the options literal `{ watch: true, ...options }` — covering a caller who **omits** the key;
-- the fallback `this.options.watch ?? true` — covering a caller who passes an explicit
- `undefined`.
-
-Both non-test construction sites in this repo pass `watch: false` explicitly and are
-unaffected either way, which is exactly why the drift was invisible to every test and
-gate: no in-repo configuration exercised the default. `MetadataPlugin` is a public export
-(`@objectstack/metadata`, `@objectstack/metadata/node`), so the consumers who did reach it
-were **external** ones — and they reached it by doing the documented-safe thing and not
-naming the key at all. What they got was the configuration both internal call sites go out
-of their way to refuse, citing an **EMFILE** hazard at both: a recursive chokidar poll
-(`usePolling: true, interval: 1000`) over the entire project root, with `node_modules`
-excluded only by chokidar's default `ignored`.
-
-The default now resolves `false`. The flag is normalized once in the constructor
-(`watch: options.watch ?? false`) rather than spelled `{ watch: false, ...options }`,
-because a spread preserves an explicitly-passed `undefined` verbatim and not every read of
-the flag routes through a nullish fallback — the `start()`-time `FileSystemRepository`
-`disableWatch` keys on `=== false`. Coercing once makes an omitted key and an explicit
-`undefined` resolve identically at every downstream read, instead of trading one
-two-spelling divergence for another.
-
-This is a default flip, **not** a capability removal: an explicit `watch: true` still
-attaches the scanner and its watcher, and the sealed-runtime carve-out
-(`bootstrap: 'artifact-only'` forces watching off even against an explicit `watch: true`)
-is untouched. Pins cover all four shapes, asserting on the **observable** — whether a
-watcher object exists on the manager — rather than on the resolved options value alone.
diff --git a/.changeset/mighty-ducks-repeat.md b/.changeset/mighty-ducks-repeat.md
deleted file mode 100644
index 39dcebe9e1..0000000000
--- a/.changeset/mighty-ducks-repeat.md
+++ /dev/null
@@ -1,40 +0,0 @@
----
-"@objectstack/plugin-audit": patch
----
-
-Published README documents the record-view audit surface that actually shipped
-
-The README was corrected against the shipped surface before record-view auditing landed,
-so it still told readers that "reads and views are not on the ledger" and that the plugin
-takes no configuration. Both became false when the `read` action, its writer and the
-`record_views` list view merged. This is a docs-only change; it needs a version bump
-because the README is in the package's published `files` array, and a correction with no
-release never reaches the npm package page at all.
-
-What the page now documents, each point verified against the source rather than against a
-description of it:
-
-- the `read` action and its writer, in the action table and in the shipped list views;
-- the per-object opt-in as what it is — an **install-time list** passed to the plugin
- constructor (`new AuditPlugin({ readAudit: { objects: [...] } })`), explicitly **not** an
- `enable.auditReads` object-metadata key. A declarable key can be set on an object in a
- deployment that never installs the plugin, producing metadata that reads as audited and
- writes nothing, which is the exact class of claim this page was corrected to remove;
-- the three settings the plugin forwards, and the one writer knob (`maxBufferedEvents`) it
- does not, which is reachable only by calling `installReadAuditWriter` directly;
-- the record-detail discriminator that keeps list and search reads out of scope, including
- the `$or` / `$not` refusal and the AND-composed predicate the security middleware leaves
- behind;
-- batched writes off the request path, the view-instant `created_at`, and the two loud
- once-only failure postures (buffer overflow, failed ledger write);
-- the two declared boundaries — a system-elevated read and a read with no principal both
- write no row;
-- that no field values are recorded, and therefore that the ledger cannot answer what a
- viewer actually saw;
-- that the shipped `record_views` view carries an `ip_address` column which is always empty
- on a `read` row, because no read-path writer stamps it.
-
-Record-view auditing adds no enterprise dependency: this package's declared edition is
-`open`, and the opt-in is ordinary plugin configuration. The two enterprise-dependent
-behaviours already annotated on the page — the hierarchy resolver and the archive
-datasource — are unchanged.
diff --git a/.changeset/mighty-rocks-jump.md b/.changeset/mighty-rocks-jump.md
deleted file mode 100644
index e0874eeeb0..0000000000
--- a/.changeset/mighty-rocks-jump.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-"@objectstack/runtime": patch
----
-
-**`DELETE` / `PATCH` / `POST` on the dispatcher's `/metadata/:type/:name` are refused with `405` instead of being answered as reads.**
-
-The `parts.length >= 2` block carried exactly one method-sensitive branch — the `PUT` save — and the read that followed it had no method guard, so every other verb fell into it and was served the ordinary metadata read. `DELETE` was the sharpest case: a caller asking to delete a metadata item received `200` plus the item document, which is indistinguishable from a successful destructive call, while nothing was deleted and `protocol.deleteMetaItem` was never invoked. No status, header or field separated any of those answers from a real `GET`.
-
-The block now answers `405 METHOD_NOT_ALLOWED` with an `Allow: GET, HEAD, PUT` header naming what it serves, aligning it with every other route in the same file (which already guard their verb). `GET`, `HEAD` and `PUT` are unchanged, and a request that passes no method still defaults to the read.
-
-Note this narrows an accepted surface: a client that was relying on `DELETE`/`PATCH`/`POST` returning the document now gets a `405`. It never performed the operation the verb named — use `GET` to read, or `packages/rest`'s `DELETE /api/v1/meta/:type/:name` for a real metadata delete.
diff --git a/.changeset/migrate-meta-reads-retired-key-sources.md b/.changeset/migrate-meta-reads-retired-key-sources.md
deleted file mode 100644
index 4d5753d6e7..0000000000
--- a/.changeset/migrate-meta-reads-retired-key-sources.md
+++ /dev/null
@@ -1,58 +0,0 @@
----
-"@objectstack/cli": patch
----
-
-fix(cli): `os migrate meta --from N` can finally open the retired-key sources it exists to rewrite (#9418)
-
-The codemod refused its own input class. A retired authorable key is a
-`retiredKey()` tombstone — `z.never()` carrying the upgrade prescription — so the
-current schema does not strip it, it **rejects** it. And a real
-`objectstack.config.ts` runs that schema itself: `os init` scaffolds
-`export default defineStack({ … })`, larger projects spread `defineView` /
-`defineAgent` / `defineFlow` across per-artifact modules, and every one of those
-`define*` helpers is a `Schema.parse()`. The rejection therefore fired while the
-config module was being **evaluated**, inside the load, before `os migrate meta`
-reached its first conversion — the command exited 1 having rewritten nothing.
-
-The message it printed was the instruction that sent the author there. The
-sentence "Run `os migrate meta --from ` to rewrite existing sources
-automatically." ships **144 times across 39 files** under `packages/spec/src`, so the v17 upgrade path closed
-a loop on itself: hit a retired key, get told to run the codemod, watch the
-codemod refuse **because of** the retired key.
-
-**The fix is a tolerant load for that one command.** There was no CLI-side
-validation step to reorder — the gate lives in the loaded module — so
-`loadConfig()` gains an opt-in `authoredSource` mode that replaces each
-`@objectstack/spec` entrypoint the config imports (the root **and** the subpaths
-the example apps author through, `@objectstack/spec/ui`, `/ai`, `/data`, …) with
-a generated shim. The shim re-exports the real module and wraps its `define*`
-helpers as try-real-then-authored: the real helper runs first, and only when the
-current schema refuses the artifact is it handed on **exactly as authored**, with
-the swallowed verdict announced on stderr.
-
-Three properties keep this a restoration rather than a widening of what the
-command accepts:
-
-- **A source that loads today loads identically** — the real helper still runs,
- so its defaults and transforms still apply (`defineForm` still moves
- `schemaId` into `data`, `defineStack` still merges actions into objects). Only
- the sources that are refused today take the new path.
-- **Validation is moved after the conversion, not skipped.** The command still
- parses the **migrated** stack through `ObjectStackDefinitionSchema` and reports
- `schemaValid`, so a source broken for reasons the chain cannot fix is still
- reported as broken — after the codemod has done the part it can.
-- **Every other command still hears the tombstone.** `os build`, `os validate`
- and `os serve` keep the default strict load: the rejection is their upgrade
- channel, and only the codemod is entitled to read past it. Pinned both ways.
-
-`os migrate meta --stored` was probed and is **not** affected: it never reads
-`objectstack.config.ts` at all — it boots from the compiled artifact and replays
-the chain over `sys_metadata` rows, and it already exits 0 in a project whose
-config carries a retired key. The defect was the authored-source arm alone.
-
-The regression proof is shaped like a real project rather than like a test — the
-retired keys are authored through `defineStack` **and** through helpers imported
-from a spec subpath, which is where a tolerance scoped to `defineStack` alone
-would still have refused. The suite that shipped alongside the defect could not
-have caught it: its fixture is a bare `export default { … }` object literal, and
-a bare literal is validated by nobody at load.
diff --git a/.changeset/migrate-stored-noncanonical-type-skipped.md b/.changeset/migrate-stored-noncanonical-type-skipped.md
deleted file mode 100644
index cc446da52d..0000000000
--- a/.changeset/migrate-stored-noncanonical-type-skipped.md
+++ /dev/null
@@ -1,71 +0,0 @@
----
-"@objectstack/metadata-protocol": patch
----
-
-fix(metadata-protocol): the stored migration reports a non-canonical stored `type` as `skipped` instead of counting it `canonical` (#8957)
-
-`migrateStoredMetadata` — the method behind `POST /meta/_migrate-stored` and
-`os migrate meta --stored` — opened every row with
-`PLURAL_TO_SINGULAR[rawType] ?? rawType`, the **manifest-collection** map. That
-map legitimately omits the metadata types that are not stack collections, so
-for a row stored under one of their plural spellings the fold was a no-op: the
-pass looked up ADR-0087 body conversions registered for a type named `fields`,
-found none, saw nothing had changed, and recorded the row `canonical`.
-
-`canonical` is counted and never itemised — by design, because on a healthy
-deployment that is every row — so the row disappeared from `report.rows`
-altogether. The verdict means "nothing to do", and there was something to do:
-the row sits in a second namespace that no registry read and no compliance
-query on the canonical type can reach.
-
-Since #8908, `publishPackageDrafts` **refuses** exactly these rows at its
-pre-flight (`STORED_TYPE_NOT_CANONICAL`). The stored migration is the door an
-operator naturally reaches for next, and it answered that the row was already
-fine. The two doors now agree.
-
-## What changed in the report
-
-The scan folds with the URL/registry map (`canonicalMetaType`) instead of the
-manifest map, and a row whose **stored** spelling is non-canonical is reported:
-
-```jsonc
-// before — the row was invisible
-{ "scanned": 1, "canonical": 1, "skipped": 0, "rows": [] }
-
-// after
-{
- "scanned": 1, "canonical": 0, "skipped": 1,
- "rows": [{
- "type": "field", "name": "showcase_task.title", "outcome": "skipped",
- "reason": "the row is stored under the non-canonical metadata type 'fields' ('fields/showcase_task.title'), and its canonical type is 'field'. …"
- }]
-}
-```
-
-The reason names the stored spelling in the same `type/name` form the publish
-refusal quotes, the canonical type, the other door's error code, and the
-re-author path. `--type field` and `--type fields` now both reach the row —
-the filter folds the same way, so the spelling an operator was just handed by
-the publish refusal is not the one spelling that fails to find it.
-
-The fold swap cannot change the answer for any spelling the old fold resolved:
-`META_URL_TO_SINGULAR` embeds every manifest spelling verbatim under a
-module-load agreement assertion, and measured on this tree the set of spellings
-where the two folds disagree is empty. The set the new fold newly resolves is
-exactly the six-member class `isNonCanonicalStoredType` derives (`fields`,
-`seeds`, `external_catalogs`, `externalCatalogs`, `translations`,
-`email_templates`), which is the set now reported.
-
-## What did NOT change
-
-The method's contract. It still canonicalizes **bodies**, and it still writes
-nothing for this class: rewriting a stored `type` is an identity move — a new
-`(org, type, name, package_id)` key, history and audit continuity to decide,
-and a collision question when the canonical row already exists — which #8908's
-ruling parked as a follow-up needing its own appetite.
-
-`storedMigrationClean` is also unchanged: `skipped` rows still do not flip it.
-This pass has no lever for the condition, so failing the verdict over it would
-give `os migrate meta --stored` a non-zero exit that no run of that command
-could ever clear. The row is reported per-row instead, and the publish door is
-what refuses it.
diff --git a/.changeset/mongo-dsn-bound-secret-injected.md b/.changeset/mongo-dsn-bound-secret-injected.md
deleted file mode 100644
index 8344f7539d..0000000000
--- a/.changeset/mongo-dsn-bound-secret-injected.md
+++ /dev/null
@@ -1,82 +0,0 @@
----
-"@objectstack/service-datasource": patch
----
-
-fix(security): a mongo datasource that binds `external.credentialsRef` and authors a connection URL now connects with the bound credential instead of none (#8696)
-
-
-
-`buildMongoUrl`'s DSN branch returned the authored `config.url` verbatim and
-applied `spec.secret` nowhere. A mongo datasource that bound its secret through
-`external.credentialsRef` (or the connection form's secret field) therefore
-connected with **whatever the URL itself carried** — which, since #8082 refuses
-a `user:password@` userinfo at the publish door, is **no credential at all**.
-Measured on `origin/main` @ `792524c22`, mongodb 7.5.0:
-
-```text
-config.url 'mongodb://app@db.internal:27017/app' + a bound secret
- -> MongoClient credentials {username:'app', password:''}
-```
-
-The connect path is fail-closed on a ref it cannot resolve, so an operator
-reasonably reads "the datasource connected" as "the bound credential was used".
-It was not: the credential was declared, resolved, injected into the factory —
-and then dropped at the last call site with no diagnostic. That is
-declared-≠-enforced (Prime Directive #10) one layer below the spec, and
-`MongoConfigSchema.url` is the contract it broke, verbatim: *"bind the secret
-(`external.credentialsRef` / the connection form's secret field) and **it is
-injected at connect time**. A bare username (`user@host1`) stays writable."*
-The arm's behaviour was decided by whether the operator happened to author a
-URL — the composed branch five lines below had honoured the secret since #4410.
-This closes the last arm of the family #7314 / #7385 / #8152 / #8875 have each
-closed one driver at a time.
-
-**The fix injects `options.auth` beside an unmodified url — it does not rewrite
-the URL.** Measured on mongodb 7.5.0 (the `MongoClient` constructor resolves
-credentials eagerly, so all of it is assertable with no server):
-
-```text
-'mongodb://app@db.internal:27017/app' + auth{app,BOUND} -> password BOUND
-'mongodb://app:embedded-legacy@h/app' + auth{app,BOUND} -> password BOUND
-'mongodb://app@h1:27017,h2:27017/app' + auth{app,BOUND} -> password BOUND
-'mongodb+srv://app@c0.example.net/app' + auth{app,BOUND} -> password BOUND
-'mongodb://app@h/app?authSource=admin' + auth{app,BOUND} -> source admin
-```
-
-So the authored URL is handed over byte for byte, no second dialect of
-`mongodb://…` enters this repo, the multi-host and `+srv` forms ride through
-unharmed, and a bound secret **wins** over a legacy password embedded in a
-stored pre-#8082 row — the same precedence the mysql arm states, reached by a
-different mechanism because the clients merge in opposite directions. The
-userinfo **username** `auth` also requires is read through the platform's own
-DSN grammar (`urlUserinfoUsername`, #8876) and percent-decoded at the call
-site: `new URL()` cannot even parse the multi-host form this schema documents,
-and a second hand-rolled copy of those boundaries is the shape #8082's ruling
-rejects by name.
-
-**A URL that names no user gets nothing, deliberately.** `auth` is not
-constructible from a password alone, and inventing an empty username is
-measurably worse than silence: `mongodb://db.internal:27017/app` carries no
-credentials at all today, and would carry `{username:''}` — a guaranteed
-handshake failure — if the arm injected regardless. Injection happens only
-where the URL already declares authenticated intent, which is also exactly what
-the composed branch has always done with the same input. Making that
-contradictory pair (a bound `credentialsRef` beside a user-less URL) loud
-belongs at the authoring door, where both halves are visible at once; it is
-filed rather than guessed at here.
-
-**Blast radius is exactly the broken class.** A datasource that binds no secret
-reaches the client byte-for-byte as before, and the `options` passthrough keeps
-arriving verbatim — the injected `auth` is merged into it, not assigned over
-it.
-
-The pin extends `__tests__/bound-secret-dsn-branches.test.ts` (the mysql half's
-file) and asserts at the **client-construction seam**: every mongo assertion
-reads `MongoClient`'s own resolved `credentials`, never the URL string the
-factory built. That distinction is load-bearing — a test asserting
-`buildMongoUrl`'s return value would have passed throughout this defect's life,
-and the postgres arm passes the equivalent config-layer assertion while still
-being broken one layer lower.
diff --git a/.changeset/mongo-options-describe-boundary.md b/.changeset/mongo-options-describe-boundary.md
deleted file mode 100644
index 166f7a008b..0000000000
--- a/.changeset/mongo-options-describe-boundary.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-"@objectstack/spec": patch
----
-
-fix(spec): correct `MongoConfigSchema.options`'s field description to state the actual refusal boundary — only `auth.password` is refused inline; `proxyPassword`, `tlsCertificateKeyFilePassword`, `key`, and `passphrase` are accepted, stored at rest in cleartext, and redacted only on read (#9254)
-
-The old string claimed "credential material is refused" for the whole `options`
-passthrough. That was true for exactly one nested path
-(`options.auth.password`, `MONGO_OPTIONS_CREDENTIAL_PATHS` / #9040) — four
-other honoured, credential-shaped keys were never refused, only redacted when
-a datasource is read back (`PASSTHROUGH_SECRET_PATHS` in
-`datasource-credential-redaction.ts`). This string renders verbatim into
-`content/docs/references/data/driver-mongo.mdx` and the Studio "Add
-Datasource" connection form's field help text, so an author configuring a
-proxy password or a TLS key passphrase was told it would be refused when it
-would actually be accepted and stored in cleartext.
-
-Describe-only: no schema shape or refusal-path change — every previously-valid
-`options` input still parses byte-identically. The corrected text agrees with
-the accurate statement #9124 landed in `content/docs/data-modeling/drivers.mdx`.
diff --git a/.changeset/mysql-dsn-bound-secret.md b/.changeset/mysql-dsn-bound-secret.md
deleted file mode 100644
index 380c76d31f..0000000000
--- a/.changeset/mysql-dsn-bound-secret.md
+++ /dev/null
@@ -1,67 +0,0 @@
----
-"@objectstack/service-datasource": patch
----
-
-fix(service-datasource): a bound `external.credentialsRef` reaches the mysql client on the DSN branch instead of being dropped (#8696)
-
-
-
-`DatasourceConnectionService` resolves a datasource's `external.credentialsRef`
-to a cleartext secret and hands it to the driver factory as `spec.secret`. The
-mysql arm then **threw it away** whenever `config.url` was present: the DSN
-string became the whole knex `connection`, and the resolved credential reached
-nothing. Measured on `origin/main`, driver `mysql`, `config.url`
-`mysql://app@db.internal:3306/app`, secret bound:
-
-```text
-knex connection: typeof=string value="mysql://app@db.internal:3306/app"
-```
-
-**This is a broken binding, not a disclosure.** Since #8082 refuses a
-`user:password@` userinfo at the publish door, a bare-username DSN plus a bound
-secret is the *only* authorable URL shape for this driver — the exact shape the
-connection form produces and the exact shape #8155's re-homing remedy tells
-operators to write. Such a datasource therefore connected **unauthenticated**,
-or failed with a driver-level auth error naming nothing about the binding, while
-its Setup page showed a credential bound and the connect path reported success.
-It is the declared-≠-enforced shape one layer below Prime Directive #10:
-`MysqlConfigSchema.url` already states the contract this code failed to keep —
-*"bind the secret … and it is injected at connect time. A bare username
-(`user@host`) stays writable."*
-
-**The fix hands mysql2 the DSN and the secret together** — `{ uri, password }`
-rather than a hand-parsed URL. mysql2 keeps owning its own DSN grammar (no URL
-parsing, no re-encoding, no second dialect of `mysql://…` in this repo), and its
-merge gives the **explicit** key precedence, so the bound credential also wins
-over a legacy password embedded in a stored pre-#8082 row — the precedence the
-postgres arm's DSN branch already declares. Measured on mysql2 3.23.1, knex
-3.3.0 and pg 8.22.0.
-
-A DSN with **nothing bound passes through unchanged**, as the bare string it has
-always been, so the entire blast radius is datasources that bind a secret — the
-ones that are broken today.
-
-Two measured findings this change deliberately does **not** act on, each filed
-on its own:
-
-- **The mongodb arm is still open.** `buildMongoUrl`'s `if (explicit) return
- explicit;` drops the bound secret the same way, so a mongo DSN datasource
- still reaches `MongoClient` with an **empty** password. The remedy is not a URL
- rewrite — `MongoClient`'s `auth` option injects beside an unmodified url, and
- it wins over an embedded userinfo password (measured on mongodb 7.5.0) — but it
- requires a username as well, and reading the url's userinfo username needs the
- platform's own DSN grammar (`new URL()` rejects the multi-host form
- `MongoConfigSchema` documents). `@objectstack/spec/data` exports the password
- half of that grammar and no username half; adding one belongs beside it rather
- than as a second copy of the userinfo boundaries here.
-- **The postgres arm passes this assertion at the config layer and is broken one
- layer below it.** `pg` merges `parse(connectionString)` **over** the explicit
- `password`, so `{connectionString, password}` resolves to the DSN's own
- (absent) password — effective `password: null`, measured on pg 8.22.0. Its
- `if (url)` branch is not fixed by symmetry with this one; the two clients merge
- in opposite directions, which is why each arm's precedence is measured rather
- than assumed.
diff --git a/.changeset/mysql-dsn-ssl-honoured.md b/.changeset/mysql-dsn-ssl-honoured.md
deleted file mode 100644
index f6a860e8f4..0000000000
--- a/.changeset/mysql-dsn-ssl-honoured.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@objectstack/service-datasource': patch
----
-
-A mysql datasource that declares TLS now gets it, on both branches of the arm and in the spelling `mysql2` can read (#8874).
-
-Two defects with one cause — `buildMysqlConnection` resolved the TLS option and then handed it to a client that could not use it, or to nobody at all.
-
-**A declared `ssl` was dropped on the DSN branch.** With a `config.url` present the arm returned before the resolved option could be attached, so a datasource that declared TLS **and** wrote a connection url negotiated none — declared, resolved, dropped, with no diagnostic — while the discrete-fields branch of the same arm carried it. Whether a connection was encrypted therefore depended on which branch of one arm the datasource happened to take. The postgres arm has honoured this case since #4410 with its reasoning written in-code, and the same argument holds here: `mysql2` reads a uri and the `ssl` option as separate channels, and keeps the explicit key.
-
-**`ssl: true` was never a `mysql2` value.** Measured on mysql2 3.23.1, `new ConnectionConfig({ …, ssl: true })` throws `SSL profile must be an object, instead it's a boolean` — and `true` is exactly what a declared `ssl: { enabled: true }` with no certificate material resolves to, as does the `config.ssl` shorthand, whose schema is a boolean and so has no other authorable value. The branch that appeared to honour the declaration was therefore throwing on every connection acquisition for the commonest way of writing it. The resolved `true` is now translated to the empty-options object it is already documented to be short for (`{}`, which mysql2 normalises to `{ rejectUnauthorized: true }` — its own default for an object, not a verification policy chosen here). Certificate objects, `false`, and a stored profile name pass through untouched.
-
-**What does not change.** The DSN branch returns an object instead of the bare connection string **only when a declared `ssl` actually resolved** (or a secret is bound, unchanged from #8696). A datasource that declared neither still gets the byte-identical string knex has always parsed for it. Where the switch does happen, knex's own parse of the string and mysql2's parse of the same value as `uri` were compared key-by-key (`host`/`port`/`user`/`password`/`database`/`charset`/`timezone`/`connectTimeout`/`flags`/`socketPath`/`multipleStatements`) across the bare-username, embedded-password, no-userinfo, portless, percent-encoded-username and query-parameter forms — identical in every case, and pinned as a test rather than measured once.
-
-Nothing that declared no TLS moves, so the behaviour change is confined to the datasources that were already broken: the ones connecting in cleartext against their own metadata, and the ones that could not connect at all.
diff --git a/.changeset/mysql-duplicate-entry-log-value.md b/.changeset/mysql-duplicate-entry-log-value.md
deleted file mode 100644
index 87235f6dfc..0000000000
--- a/.changeset/mysql-duplicate-entry-log-value.md
+++ /dev/null
@@ -1,25 +0,0 @@
----
-"@objectstack/objectql": patch
----
-
-Keep a caller's value out of the server log when MySQL reports a duplicate entry
-
-The driver-fault redaction added for #8682 replaces the bound statement in a logged
-write fault and keeps the database's own diagnostic, because that diagnostic names the
-failing identifier an operator needs. On MySQL's `ER_DUP_ENTRY` (1062) that premise does
-not hold: the template is `Duplicate entry '' for key ''`, so the
-conflicting value is in the diagnostic rather than in the statement and survived the cut.
-
-The tail is still kept — including `for key ''`, which is the answer to "which
-constraint?" — and only the value slot is replaced:
-
-```
-before Duplicate entry 'acme@example.com' for key 'crm_account.email' [statement and bound values redacted]
-after Duplicate entry [value redacted] for key 'crm_account.email' [statement and bound values redacted]
-```
-
-Also closed: a value spelled with `" - "` in it used to leave a fragment behind, because
-the statement cut takes the last separator and that separator was inside the value.
-
-Identifier-bearing diagnostics on every dialect are unchanged, the rethrown error is
-untouched, and no HTTP response moves — this narrows one log slot only.
diff --git a/.changeset/mysql-ssl-doc-comment-verbatim.md b/.changeset/mysql-ssl-doc-comment-verbatim.md
deleted file mode 100644
index af024d7a2c..0000000000
--- a/.changeset/mysql-ssl-doc-comment-verbatim.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@objectstack/spec": patch
----
-
-fix(spec): correct the stale "passed to `mysql2` verbatim" TS doc comment on `MysqlConfigSchema.ssl` (mysql.zod.ts) — since #8874, the resolved `true` is translated into mysql2's own default TLS options (`rejectUnauthorized: true`) before mysql2 sees it, because mysql2 rejects a bare boolean outright. Comment-only; accept/reject behaviour is unchanged, and the shared `DriverSslToggleSchema.describe()` (correct for the postgres/turso arms, which do pass the boolean verbatim) is untouched (#9125)
diff --git a/.changeset/mysql-unbacked-conflict-target-preflight.md b/.changeset/mysql-unbacked-conflict-target-preflight.md
deleted file mode 100644
index f05981d3fa..0000000000
--- a/.changeset/mysql-unbacked-conflict-target-preflight.md
+++ /dev/null
@@ -1,68 +0,0 @@
----
-"@objectstack/driver-sql": minor
----
-
-fix(driver-sql): MySQL refuses an upsert whose `conflictKeys` no PRIMARY KEY or UNIQUE index backs — calls that previously "resolved" now throw (#8621)
-
-**This narrows MySQL's accept set.** A `SqlDriver.upsert(object, data, conflictKeys)`
-call on MySQL whose conflict target is backed by no PRIMARY KEY and no UNIQUE
-index used to resolve; it now throws `VALIDATION_ERROR` / 400. That is why this
-is a `minor` and not a patch: code that ran without error against MySQL will
-start failing, deliberately, and the rows it was writing were not the rows the
-caller asked for.
-
-SQLite and Postgres have refused this exact call since #8445 / #8567, with this
-exact sentence. MySQL did not, and could not: knex compiles
-`onConflict([...]).merge(...)` on `mysql2` to `ON DUPLICATE KEY UPDATE`, which
-takes **no conflict target at all**, so the named keys are dropped before the
-statement leaves the process and the server is never asked to find an index for
-them. The existing refusal classifies an error the server raised, so on MySQL it
-had nothing to classify.
-
-Measured on live MySQL 8.0.46 — `email` is the column the caller names, `tax_id`
-carries the only unique index:
-
-```
-seed upsert({email:'a@b.com', tax_id:'T-1', title:'first'}, ['email']) -> resolved
-B upsert({email:'other@b.com', tax_id:'T-1', title:'second'}, ['email']) -> resolved
- ONE row: merged on `tax_id`, which the caller never named, across two
- different `email` values.
-D seed, then upsert({email:'a@b.com', tax_id:'T-2'}, ['email']) -> resolved
- TWO rows, both `email='a@b.com'`: the merge that WAS asked for did not
- happen either.
-```
-
-So the failure being replaced is not an illegible error — it is a silent wrong
-write. `upsert` now consults the table's physical keys before compiling on MySQL
-and answers the wording, `code` and `status` the other two dialects already
-answer (#5240 — one condition, one wording).
-
-**What this means for an existing MySQL deployment.** The calls that change are
-exactly those naming a conflict target no key covers — the same calls that have
-always been errors on SQLite and Postgres. The most likely one to surface is a
-tenant-scoped `unique: true` field: its index materializes as the composite
-`(COALESCE(organization_id, '__global__'), field)` (ADR-0120 D3), so
-`conflictKeys: ['field']` alone is not backed by it. The remedy is the one the
-refusal already prints: declare the column(s) `unique: true` and re-run schema
-sync, name the full composite, or upsert on the primary key.
-
-Deliberately unchanged:
-
-- **SQLite and Postgres.** They already refuse this from the server, and they
- attach the server's own sentence as `cause` — ground truth a pre-flight cannot
- reconstruct. Running the pre-flight there would replace a planner verdict with
- an introspection verdict for no gain.
-- **The default `['id']` path.** The pre-flight runs only when the caller names
- a target; the default is this driver's own primary key on every table it
- creates, so probing it would add a round trip to every ordinary upsert to
- answer a question with only one possible answer.
-- **Anything the pre-flight cannot prove.** A failed introspection, a table
- reporting no keys at all (indistinguishable from a table that does not exist),
- and a possibly stale cache all proceed rather than refuse — the cache is
- re-read from the database before any refusal is thrown.
-
-**Not fixed here, and filed as #8755:** `ON DUPLICATE KEY UPDATE` carries no
-conflict target even when the named one IS backed, so on MySQL a second unique
-index can still absorb the conflict and merge on a key the caller never named.
-This change closes the unbacked-target hole; it does not make MySQL honour
-`conflictKeys` as a target.
diff --git a/.changeset/mysql-upsert-ambiguous-conflict-target.md b/.changeset/mysql-upsert-ambiguous-conflict-target.md
deleted file mode 100644
index 16149d5518..0000000000
--- a/.changeset/mysql-upsert-ambiguous-conflict-target.md
+++ /dev/null
@@ -1,29 +0,0 @@
----
-"@objectstack/driver-sql": minor
----
-
-fix(driver-sql): refuse a MySQL upsert whose named conflict target another UNIQUE key can absorb (#8755)
-
-`ON DUPLICATE KEY UPDATE` — the only merge statement MySQL compiles — carries no
-conflict target, so the merge lands on whichever UNIQUE key the row collides with
-first. `#8621` closed the half where nothing backed the named target; this closes
-the half where the target IS backed and a *second* UNIQUE key absorbs the
-conflict instead.
-
-Measured on live MySQL 8.0.46, `email` and `tax_id` both `unique: true`, the
-caller naming `email`: the second upsert merged on `tax_id`, across two different
-values of the named key, leaving one row and no error. The identical call on
-SQLite and PostgreSQL raises `UNIQUE constraint failed: …tax_id` and leaves the
-seeded row untouched.
-
-**Accept-set change, MySQL only.** An `upsert(object, data, conflictKeys)` naming
-a non-primary target on a table that carries any other UNIQUE key is now refused
-before the statement is compiled — `code: 'VALIDATION_ERROR'`, `status: 400`,
-nothing written and no auto-number reserved. The message names the colliding
-index and both workarounds: drop or rename the extra UNIQUE key, or run the
-object on a dialect that honours the target.
-
-Deliberately unchanged: a table whose only UNIQUE key IS the conflict target (the
-common shape) merges exactly as before, as do the `conflictKeys`-less default and
-an explicitly named primary key. The MySQL dialect limit and that residue are
-documented under *Database Drivers → MySQL*.
diff --git a/.changeset/mysql-upsert-cross-row-identity-merge.md b/.changeset/mysql-upsert-cross-row-identity-merge.md
deleted file mode 100644
index 1d868ee7d2..0000000000
--- a/.changeset/mysql-upsert-cross-row-identity-merge.md
+++ /dev/null
@@ -1,45 +0,0 @@
----
-"@objectstack/driver-sql": minor
-"@objectstack/spec": patch
----
-
-fix(driver-sql): refuse — and roll back — a MySQL upsert that merges onto a row the caller never identified (#8807)
-
-`ON DUPLICATE KEY UPDATE` carries no conflict target, so on MySQL a merge lands on
-whichever UNIQUE key the row collides with first. `#8621` closed the half where
-nothing backed a caller-named target; `#8755` closed the half where a rival key
-could absorb a caller-named one. This closes the residue those two left by
-construction: the `conflictKeys`-less call and the `['id']` call, which compile
-byte-identically and which no pre-flight can judge, because neither names anything.
-
-Measured on live MySQL 8.0.46, `email` and `tax_id` both `unique: true`, **no**
-`conflictKeys`: seeding `{email:'d@b.com', tax_id:'T-9'}` inserted one row, and
-`{email:'e@b.com', tax_id:'T-9'}` then resolved with no error — one row, the
-*seeded* one, its `email` rewritten `d@b.com` to `e@b.com`, and the id the caller
-was handed back present in no row at all. The identical pair on SQLite raises
-`UNIQUE constraint failed: …tax_id` and leaves the seeded row untouched.
-
-Per the maintainer ruling on #8807 this enforces a contract principle, not a MySQL
-detail: *an `upsert` must never modify a row whose identity the caller did not
-supply and whose conflict key it did not name.*
-
-**Accept-set change, MySQL only.** After the statement and inside the same
-transaction, the driver checks whether the row it landed on is the one the call
-supplied. If it is not, the write is **rolled back** and the call refuses with
-`code: 'VALIDATION_ERROR'`, `status: 400`, naming the UNIQUE key that absorbed the
-merge and stating that nothing was changed.
-
-The check is exact rather than heuristic — `id` is insert-only on the merge path
-(#8622), so a row merged on the primary key always still carries the supplied id
-and a row merged on any other key never does — which is why it has no false
-refusals.
-
-Deliberately unchanged: tables whose only key is the primary key are not verified
-and open no transaction, so the ordinary upsert keeps its single round trip; every
-insert and every re-upsert of the same row still merges; the caller-named
-single-unique-key fast path is untouched; and SQLite and PostgreSQL are unaffected,
-because `ON CONFLICT (...)` already honours the named arbiter. The lifecycle
-archiver's hot→cold copy passes by construction — it supplies each row's own id —
-and of the two objects declaring `lifecycle.archive`, neither carries a
-non-primary unique field. The dialect limit is documented under
-*Database Drivers → MySQL*.
diff --git a/.changeset/nine-camels-behave.md b/.changeset/nine-camels-behave.md
deleted file mode 100644
index 8984fb2ee9..0000000000
--- a/.changeset/nine-camels-behave.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@objectstack/spec": patch
----
-
-`FieldSchema` docs now pin the ruled multi-value lookup empty representation (#9447, maintainer ruling 2026-08-18): an emptied multi-value lookup reads back as `[]`, never `null` — binding for every writer (cascade repair, form clears, API writes) — and `required` on a multi-value lookup means non-empty array, so an emptied required set fails validation loudly.
diff --git a/.changeset/notify-node-email-template-locale-bridge.md b/.changeset/notify-node-email-template-locale-bridge.md
deleted file mode 100644
index d5c72cc3c3..0000000000
--- a/.changeset/notify-node-email-template-locale-bridge.md
+++ /dev/null
@@ -1,40 +0,0 @@
----
-"@objectstack/spec": minor
-"@objectstack/service-automation": minor
-"@objectstack/service-messaging": minor
----
-
-feat(automation): flow `notify` nodes can reference an email template for localized delivery — `template` + `templateData` on `NotifyNodeConfig`, resolved by `(name, recipient locale)` at delivery time (#9205)
-
-Ruled 「立项,走 emailTemplates 路线」: instead of widening the `flows`
-translation surface (whose guidance excludes notification text, #7646), a
-`notify` node now bridges to the existing localized email-template subsystem.
-
-- **Spec** — `NotifyConfigSchema` gains `template` (a `sys_email_template`
- name, read raw like `topic`/`channels`) and `templateData` (render context
- for the template's `{{var}}` holes; values interpolate `{token}` templates
- per run) as the localizable alternative to inline `title`/`message`. Inline
- strings stay fully valid and byte-identical for existing flows — they are
- the non-localizable path, and the describes now say so. A node carrying BOTH
- paths, or `templateData` without `template`, or NEITHER path, is refused
- loudly with the fix in the message (the `objectNavTargetExclusivity`
- posture: unrepresentable over silent precedence).
-- **service-automation** — the notify executor forwards the template
- reference and its interpolated render context in the emit payload (the
- outbox snapshots it onto each delivery row), and no longer demands an
- inline title when a template is referenced.
-- **service-messaging** — the email channel routes a template-carrying
- delivery through `IEmailService.sendTemplate({ template, locale, data })`,
- resolving the recipient locale per delivery: `payload.locale` if the
- producer set one, else the deployment default
- (`II18nService.getDefaultLocale()`, the #8195 ruled source), else
- `sendTemplate`'s documented `en-US` ladder. Template-resolution failures
- (`TEMPLATE_NOT_FOUND` / `TEMPLATE_INACTIVE` / `MISSING_VARIABLES`, and an
- email service without `sendTemplate`) are graded `permanent` — dead
- immediately with the code on the delivery row, instead of burning the retry
- schedule on metadata that cannot fix itself.
-
-The inbox channel keeps its existing rendering (notification title/body,
-falling back to the topic on the template path): it has no locale-capable
-rendering seam to the email-template subsystem today, and that gap is
-documented in the PR rather than papered over with a duplicated resolver.
diff --git a/.changeset/object-index-unknown-keys-refused.md b/.changeset/object-index-unknown-keys-refused.md
deleted file mode 100644
index baec056fcf..0000000000
--- a/.changeset/object-index-unknown-keys-refused.md
+++ /dev/null
@@ -1,57 +0,0 @@
----
-"@objectstack/spec": minor
----
-
-feat(spec): refuse undeclared keys on object `indexes[]` entries (#4001 批 20 site 14, the held `IndexSchema`)
-
-**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
-launch-window convention ships it as `minor`; the migration prescription is
-registered under protocol major 18, where `os migrate meta` users will look).
-
-`IndexSchema` — 批 20's one deliberately-held site — is now `strictObject` like
-its thirteen siblings. The hold was a measured #5114-class risk, not an
-unfinished to-do: objectui's embedded index editor shipped a drifted
-hand-copied schema (`FALLBACK_SCHEMAS.index`) offering `where` for a
-partial-index predicate and `brin` in an algorithm enum, spliced its form
-output into `object.indexes[]` and PUT the whole object — so closing the shape
-would have 422'd a control the console itself rendered. objectui#4772
-converged that editor to the declared surface (`name` / `fields` / `unique`),
-spending the hold's evidence.
-
-Before this change an undeclared key on an index parsed clean and was silently
-dropped: an admin filling the old "Partial-index predicate" control got a
-green save while no driver ever read the predicate
-(`SqlDriver.syncDeclaredIndexes` consumes `name`/`fields`/`unique` only).
-
-**What is refused:** any key the shape does not declare, with a prescriptive
-message naming the surface and the offending key. `where` carries a curated
-guidance entry — the predicate belongs at the database layer
-(`CREATE [UNIQUE] INDEX … WHERE` from a runtime migration, the
-`ensureOverlayIndex` pattern), deliberately NOT a rename onto the retired
-`partial` tombstone (a suggestion pointing into a second rejection).
-
-**What stays accepted:** every declared key byte-identically, including every
-ADR-0120 `unique` scope spelling — and the protocol-17 `type`/`partial`
-tombstones keep answering their own migration prescription rather than
-degrading to a generic `unrecognized_keys`.
-
-## FROM → TO
-
-```ts
-// before — parsed green; the predicate was silently dropped, the index built FULL
-indexes: [{ fields: ['status'], where: "status = 'open'" }]
-
-// after — rejected with the database-layer prescription; declare only what is materialized
-indexes: [{ fields: ['status'] }]
-// …and issue `CREATE INDEX … WHERE ` from a runtime migration when
-// a partial index is actually needed.
-```
-
-There is deliberately no automatic rewrite: an undeclared key here either
-names a capability the declaration surface does not deliver (blessing it would
-be declared-but-unenforced surface, ADR-0078) or is a spelling of a declared
-one, which the rejection names. `os migrate meta` surfaces the change as a
-structured TODO (semantic entry `object-index-unknown-keys-refused`, protocol
-major 18 — this refusal is not part of the v17.0.0 cut).
-
-
diff --git a/.changeset/object-write-gate-five-gating-rules.md b/.changeset/object-write-gate-five-gating-rules.md
deleted file mode 100644
index 9379ee1f8c..0000000000
--- a/.changeset/object-write-gate-five-gating-rules.md
+++ /dev/null
@@ -1,29 +0,0 @@
----
-"@objectstack/lint": minor
----
-
-feat(lint): the five gating object rules cross the runtime publish gate — `object` writes are now judged by `validateFunctionalCompleteness`, `validateManagedApiMethods`, `lintAutonumberFormats`, `validateRuleCompilability` and `validateRuleSchemaFormats` (#4716)
-
-An `active`-state `object` save through `saveMetaItem` (Studio's field editor,
-REST `/meta` item CRUD, an MCP/AI author) is now refused with the existing 422
-`invalid_metadata` envelope when it carries a defect these five rules judge:
-an inert `summary`/`lookup`/`select` shape, a managed-API verb the object's own
-affordances refuse, an autonumber format referencing an unknown field, a
-`format` regex or `json_schema` schema the runtime's own compilers reject, or a
-`json_schema` `format` name ajv would silently drop. All five already gated
-`os validate` / `os build` / `os lint`; the runtime door — the only door a
-tenant overlay row has — ran none of them.
-
-Scope is deliberately the five **gating** rules only (the #4716 adjudication):
-the six advisory-tier object rules stay off the runtime surface, so a clean
-save's response is byte-identical and no new advisory volume reaches Studio's
-designer. Draft saves are untouched (D1), stored rows keep being served
-(ADR-0087 asymmetry — the gate's differential blames a write only for what it
-adds), and `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still degrades the refusal to
-a loud log for migration windows.
-
-Boot-path note: the two schema-judging rules load ajv lazily, only when the
-judged snapshot actually carries a `json_schema` validation — an ordinary
-field edit still loads no compiler, which `runtime-lazy-deps.test.ts` now pins
-as a three-tier contract (parsers never; ajv never without a schema; ajv
-required, on demand, when one is present).
diff --git a/.changeset/objectql-plugin-registry-read-seams.md b/.changeset/objectql-plugin-registry-read-seams.md
deleted file mode 100644
index 32b11b89aa..0000000000
--- a/.changeset/objectql-plugin-registry-read-seams.md
+++ /dev/null
@@ -1,61 +0,0 @@
----
-"@objectstack/objectql": patch
----
-
-fix(objectql): `ObjectQLPlugin`'s three registry reads stop inventing an empty registry — one of them silently skipped schema sync for every object at boot (#9285)
-
-`ObjectQLPlugin` read the registered object set in three places, all spelled
-`this.ql.registry?.getAllObjects?.() ?? []`. That expression folds three
-different facts into one value:
-
-1. the registry answered, and holds no objects;
-2. the engine exposes no `registry` at all;
-3. the registry exposes no `getAllObjects` — a **structural** omission that
- never throws, so it is invisible precisely when it is wrong.
-
-Only (1) is truthfully *"no objects"*. #8895 ruled this family **discriminate or
-propagate**; #9002 and #9154 applied it to the two delete-cascade seams and the
-roll-up summary index. This closes the same shape in the plugin, where the
-consequential seam is at **boot**.
-
-The three seams get three different answers, and the difference is the fix:
-
-- **`syncRegisteredSchemas` — propagates.** Its next line is
- `if (allObjects.length === 0) return;`, so an invented empty answer meant **no
- registered object's schema was synced to any driver** — no table created, no
- column added — silently, at boot, with the plugin reporting a clean start.
- Failing the boot is more truthful than starting against a store whose DDL
- never ran. On the `metadata:reloaded` path the existing caller already catches
- this and reports it at `error` (#4632), so propagation there is a loud
- durability report rather than a dead kernel.
-- **`reconcileFederatedBindings` — reports at `error`, then degrades.** The pass
- exists to *name* the federated objects it could not bind ("a boot with nothing
- to report says nothing"), so an unreadable registry making it report nothing
- was exactly the silence it was written to prevent. It stays exception-proof:
- it is a post-hoc reconciliation run after every `start()`, deliberately not a
- boot gate.
-- **`runGovernanceInventory` — reports at `warn`, then skips.** This seam
- carried **two independent swallows** (`?.()` *and* a wrapping
- `try { … } catch { return [] }`), so a *throwing* registry was
- indistinguishable from an empty one. Feeding the audit an invented empty
- object set is worse than silence: with no objects, every handler declared *on*
- an object reconciles as an "undeclared handler … REFUSED at dispatch", so an
- unreadable registry accused a healthy deployment. The inventory is warn-only
- and exception-proof by contract, so it reports and skips instead of
- propagating, and leaves its report fingerprint untouched so the next
- successful run is not suppressed as "unchanged".
-
-All three now read through one shared helper that throws rather than inventing,
-naming the consequence; a registry that *throws* propagates its own error
-verbatim.
-
-This is a **structural** close, not a live defect — re-derived on this tree:
-`SchemaRegistry.getAllObjects()` is a walk over in-memory `Map`s calling
-`resolveObject()`, which returns `undefined` on every failure branch it models
-and never throws, and `ObjectQL.registry` is a getter over a field-initialized
-`SchemaRegistry`, so for a real engine neither optional link can short-circuit.
-The reach that is real is a duck-typed `ql` — an incomplete test double, which
-#9154 measured shipping in nine suites at once.
-
-The `objectsRegistered` count in the `ObjectQL engine started` info log is
-deliberately unchanged: a wrong `0` there costs one advisory line and no data.
diff --git a/.changeset/olive-donkeys-brake.md b/.changeset/olive-donkeys-brake.md
deleted file mode 100644
index 3dfb146f7a..0000000000
--- a/.changeset/olive-donkeys-brake.md
+++ /dev/null
@@ -1,27 +0,0 @@
----
-'@objectstack/driver-sql': patch
----
-
-Report an un-run MySQL widening ALTER at `error`, naming the fix
-
-Boot schema-sync widens legacy MySQL `TIMESTAMP` columns to `DATETIME(3)` and
-zero-precision `TIME` columns to `TIME(3)`. When that DDL cannot run — most
-often another session holding the table's metadata lock — the failure is
-swallowed on purpose so a migration never takes boot down. It was reported at
-`warn`.
-
-That is the case AGENTS.md's degradation rule names for `error` by name: after
-the swallow the platform boots, serves traffic and looks entirely normal, while
-the DDL that was supposed to run did not. An un-widened `TIMESTAMP` keeps
-truncating milliseconds and an un-widened `TIME` keeps rounding fractional
-seconds to whole ones, against a canonical storage form that promises the
-milliseconds are kept, and nothing else reports the column as outstanding.
-
-Both lines now report at `error` and say what to do about it — identify the
-metadata-lock holder, end it, then re-run `os migrate apply` or restart, the
-widening being idempotent. Control flow is unchanged: the swallow stays, and
-the deferred-DDL flush keeps its loud refusal.
-
-`scripts/check-durability-degradation-log-level.mjs` gains `runWideningAlters`
-in its durability vocabulary, so the class stays fixed rather than these two
-sites.
diff --git a/.changeset/olive-pandas-repeat.md b/.changeset/olive-pandas-repeat.md
deleted file mode 100644
index f2079f4654..0000000000
--- a/.changeset/olive-pandas-repeat.md
+++ /dev/null
@@ -1,7 +0,0 @@
----
-'@objectstack/spec': minor
----
-
-Declare 14 registry-published props on the react-tier `ObjectForm` block (ADR-0082 D4 declaration parity, #9392): `modalCloseButton`, `contentLayout`, `confirmOnDiscard`, `customFields`, `readOnly`, `submitText`, `cancelText`, `nextText`, `prevText`, `showSubmit`, `showCancel`, `showReset`, `successMessage`, `resetOnSuccess` — the inputs objectui#4648/objectui#4901 published on the `object-form` registration that the react-blocks channel of the spec never declared. Descriptions are adapted from objectui's own registration; the generated react-blocks contract (`skills/objectstack-ui`) picks them up.
-
-Three registry inputs are deliberately NOT declared and are instead baselined with recorded reasons (maintainer ruling 2026-08-18 on #9392): `initialData` (alias spelling of `initialValues` — aliases are not promoted into spec), `mobile` (internal presentation override, not an authoring surface), and `navigateOnSuccess` (parked pending the action-success-navigation family ruling; revisit tracked on #9392).
diff --git a/.changeset/org-identifier-session-provenance.md b/.changeset/org-identifier-session-provenance.md
deleted file mode 100644
index e66af0a2d8..0000000000
--- a/.changeset/org-identifier-session-provenance.md
+++ /dev/null
@@ -1,24 +0,0 @@
----
-'@objectstack/service-storage': patch
-'@objectstack/plugin-audit': patch
----
-
-Attachment access hooks: read the caller's org under the blessed `organizationId` name
-
-`callerContext()` in the `sys_attachment` access kit built its fallback
-execution envelope from `session.tenantId` — an alias removed from the
-hook/action session surface in v11 (#3290). `HookContextSchema` strips a
-`tenantId` key and the engine's `buildSession` only ever emits
-`organizationId`, so on every call that reached the session fallback (no
-execution context riding along) the envelope handed to
-`ISharingService.canEdit` carried **no organization at all**. Parent-record
-access for attachments was therefore evaluated without the caller's active
-org on that path. It now reads `session.organizationId`, matching the
-`sys_comment` kit, which already did.
-
-The `sys_comment` kit's own `callerContext()` had the same read as a dead
-first arm (`s.tenantId ?? s.organizationId`); the arm is removed. That half
-is behaviour-neutral — the fallback already carried the value.
-
-Both kits gain coverage of the session-fallback path in both directions: the
-blessed name is read, and a stray removed-alias key does not become the org.
diff --git a/.changeset/org-less-customer-activity-1395.md b/.changeset/org-less-customer-activity-1395.md
deleted file mode 100644
index f4d0d656b4..0000000000
--- a/.changeset/org-less-customer-activity-1395.md
+++ /dev/null
@@ -1,10 +0,0 @@
----
-'@objectstack/service-automation': patch
-'@objectstack/plugin-approvals': patch
----
-
-`sys_automation_run` resolves its organization from the DECLARED `AutomationContext.tenantId` and from no other spelling (cloud#1395).
-
-The suspended-run store read `context.organizationId ?? context.tenantId`. `AutomationContext` declares `tenantId` and not `organizationId`, and no producer writes the latter — `RecordChangeTrigger.buildContext` maps the hook session's organization onto `tenantId`, and the runtime's automation domain sets `tenantId` directly. The dead limb was not inert: the one test covering `sys_automation_run.organization_id` fed the phantom key, so the column's only coverage exercised a path production cannot reach and said nothing about the live one. The limb is removed, the fixture speaks the declared contract, and a test now asserts the absence so restoring the alias goes red.
-
-Both `sys_approval_request.organization_id` and `sys_automation_run.organization_id` now document the measured attribution defect this uncovered and the negative control that makes it a defect: on a walled single-database boot these two tables stored customer activity with no organization (27/27 and 31/31) while `sys_audit_log` (1669 rows) was correctly attributed on the same boot, because the audit writer resolves the organization from the record the row is ABOUT rather than from the acting context. The write-side repair is not in this change — which column a side-table row should follow is an open contract question, since the audit resolver is scope-pinned to audit stamping by the #8778 ruling. The current behaviour is pinned by test so the fix must promote the assertion rather than quietly satisfy it.
diff --git a/.changeset/org-scoped-meta-read-door.md b/.changeset/org-scoped-meta-read-door.md
deleted file mode 100644
index 6302655e67..0000000000
--- a/.changeset/org-scoped-meta-read-door.md
+++ /dev/null
@@ -1,74 +0,0 @@
----
-"@objectstack/metadata-core": patch
-"@objectstack/metadata-protocol": patch
-"@objectstack/rest": patch
----
-
-fix(rest): org-overridable metadata is served back by every `/meta` read door, not just persisted (#9454)
-
-
-
-A runtime `PUT` of an org-overridable metadata type — `view`, `dashboard`,
-`report`, `translation`, `email_template` — answered **200** with a receipt
-reporting `state: 'active'` plus a version and sequence number, **persisted the
-row with its `organization_id`**, and was then served back by **nothing**: the
-direct `GET` answered 404, the scoped listing was unchanged, the unfiltered
-listing was missing it, and the browser rendered an empty view or "Dashboard Not
-Found". The platform reported success in the same breath as not delivering the
-work, which is declared ≠ enforced in the direction hardest for an author to
-notice — the write path says everything worked.
-
-**The write door was correct as-is.** The row really is persisted, so the
-receipt is truthful; this was persisted-but-not-served, never a silent write
-no-op. **The overlay-resolution layer was correct too**, and type-agnostic:
-`getMetaItem` resolves `(orgId ? findOverlay(orgId) : undefined) ??
-findOverlay(null)`, `getMetaItems` unions both scopes under org-wins precedence,
-and `getMetaItemLayered` even reports `overlayScope`. The defect was that the
-REST read doors **never stated the scope**, so every one of them asked for the
-env-wide partition and the org partition was never consulted.
-
-**The repair is one registry-derived predicate, threaded at the read doors.**
-`organizationIdForMetaRead` joins `organizationIdForMetaWrite` in
-`metadata-core`, deriving from the same `allowOrgOverride` registry flag, so
-read scope and write scope cannot drift and a registry entry flipping the flag
-moves both doors together. It is threaded through the **already-memoised**
-`resolveExecCtx`, so no new per-request organization resolution is introduced.
-
-⛔ **Not a bare `ctx?.tenantId` at each site**, and the reason is measurable
-rather than stylistic: deployments predating the #6190 ruling hold **phantom
-org-scoped rows for types the registry declares non-overridable** (the runtime
-used to stamp `organization_id` on every type). Boot hydration deliberately
-walks past those rows, so they are dead. A read door naming the org for *every*
-type would resolve them again — serving, on the read side, a document that
-vanishes at the next restart.
-
-**`getMetaItemCached` gains an `organizationId` member** — it was the only meta
-read verb that could not express one, having hard-coded a two-key delegation to
-`getMetaItem`. The organization is also folded into its **ETag**. The mechanism
-differs from `locale` and the difference is stated rather than glossed: `locale`
-is invisible to the hash (the body is translated after the validator runs), so
-folding it in was the only way it could vary the validator at all, whereas the
-org-resolved document *is* the thing hashed. No cache leak is claimed — the
-directive is `private, no-cache` and there is no server-side cache entry keyed by
-type+name. It is folded in because that makes scope a **declared** property of
-the validator instead of an emergent property of the body.
-
-**Both REST branches are fixed, which is the half-fix this card could easily
-have shipped instead.** `view` and `dashboard` share one mechanism but reach it
-through two different arms: `view` takes the cached arm (`getMetaItemCached`),
-while `dashboard` bypasses the cache via `isDashboardType` and takes the
-uncached arm. Both omitted the org, so a fix applied to one arm would have
-fixed exactly one type while the receipt kept claiming success for the other.
-The scope is now resolved **above** the fork, so the two arms cannot disagree.
-
-The regression proof drives real REST routes against a real protocol over a stub
-engine — write-then-read agreement on **one boot**, for all five types, through
-both arms. Its most important assertions are the ones that do **not** merely
-check the item comes back: an org-less caller and a **second organization** must
-each be refused it. An org-blind overlay fallback would satisfy every other
-assertion in the file while matching an arbitrary tenant's row.
diff --git a/.changeset/organization-probe-discriminate.md b/.changeset/organization-probe-discriminate.md
deleted file mode 100644
index 4fcb24ac1d..0000000000
--- a/.changeset/organization-probe-discriminate.md
+++ /dev/null
@@ -1,47 +0,0 @@
----
-"@objectstack/objectql": patch
----
-
-fix(objectql): a failed `sys_organization` probe stops reading as "this install has no organizations" (#9261)
-
-`probeInstallOrganizations` — the read the #8844 system-write organization
-resolution decides on — sat behind a bare `} catch { ids = [] }`. Every failure
-answered with the count that means *none*, and `resolveSystemWriteOrganization`
-maps 0 / 1 / 2+ organizations to *proceed unstamped* / *stamp the derived id* /
-*refuse*. So one transient probe failure silently skipped **both** halves of the
-2026-08-15 ruling:
-
-- on a `single`-posture install that really has one organization, system-context
- inserts (a hook, a cron tick, a `runAs: system` flow) landed **unstamped** —
- filing the row under the `__global__` pseudo-tenant and forking exactly the
- per-organization autonumber counter and partitioned unique index the ruling
- exists to protect;
-- on a multi-organization install, the refusal the ruling mandates
- (`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`) **never fired** — fail-open on a
- guard that must be loud.
-
-Aggravated by the memo: the invented answer was cached in
-`organizationProbeMemo`, so the outage's consequence outlived the outage — every
-later system write inherited "no organizations" until an organization write
-happened to clear it.
-
-The probe now discriminates by error TYPE, the disposition ADR-0110 D3 requires
-("the probe found nothing" and "the probe could not run" are different facts):
-
-- **benign** — `sys_organization` routes but its table was never provisioned
- (schema sync not run yet). It cannot hold a row, so zero really is the count,
- and first boot still proceeds unstamped. Asked through the shared
- `isMissingTableError` predicate (`@objectstack/metadata/errors`), the same call
- the file's sibling read seams make, never a hand-rolled code test.
-- **everything else** — connection loss, pool exhaustion, a timeout mid-boot, a
- datasource that never connected, a permission denial — propagates with its
- envelope intact, and is **not memoised**. The write that asked fails loudly
- instead of being filed under a guessed topology, and the next write re-probes
- rather than inheriting the guess. No new error code and no new response field.
-
-Measured rather than assumed: the old comment's "`sys_organization` may not be
-registered at all (a lean embedding, a bare-kernel test)" is not a second benign
-cause. An object missing from the registry does not fail the read at all on a
-driver that tolerates an unknown table (`find` returns `[]` through the normal
-path), a strict driver surfaces it as the missing table above, and an engine
-with no driver fails the write on its own object one frame before the probe runs.
diff --git a/.changeset/package-delete-driver-fault-status.md b/.changeset/package-delete-driver-fault-status.md
deleted file mode 100644
index f0a1927d25..0000000000
--- a/.changeset/package-delete-driver-fault-status.md
+++ /dev/null
@@ -1,56 +0,0 @@
----
-"@objectstack/service-package": patch
-"@objectstack/rest": patch
----
-
-fix(rest): `DELETE /api/v1/packages/:id` answers a driver fault as a 5xx, and stops swallowing coded refusals (#8275)
-
-`packageService.delete` swallowed every throw and reported failure by returning
-a bare `{ success: false }`, so the door answered
-`400 PACKAGE_DELETE_FAILED`. The statement behind it is
-`DELETE FROM sys_packages WHERE id = ? [AND version = ?]`, so a missing table, a
-lock timeout or a foreign-key restriction — a **server** fault — was answered as
-a client error: it invited the caller to fix a request that was never the
-problem, and it hid a real fault from every dashboard that buckets by status.
-
-This is the sibling of what #8016 fixed on the throw path and #8131 fixed for
-`publish`. `service-package` had been left **partially converted** by #8131 —
-the same service answering two different classifications for the same kind of
-fault — and this closes that.
-
-**Two changes, both small:**
-
-- `delete`'s catch re-throws a throw that **declares its own status**, so a
- coded refusal reachable from this call path keeps the producer's status and
- code through the door's #8016 mapping (a `409 DESTRUCTIVE_CHANGE` stays a
- 409) instead of being flattened into one 400. It reuses the existing
- `declaresHttpAnswer` predicate rather than declaring a second one.
-- an undeclared throw stays a returned failure, and the door answers it **500**.
-
-⛔ The discriminant is the **status** channel, never `.code`. Every SQL driver
-populates a string `code` on its errors (`ERR_SQLITE_ERROR`, `SQLITE_ERROR`, the
-SQLSTATE `42P01`, `ER_NO_SUCH_TABLE`), so a `.code`-reading predicate re-throws
-genuine driver faults as if they were refusals — resolving them to a `500
-INTERNAL_ERROR` that carries the driver's own message. Pinned per dialect in
-`delete-driver-fault.test.ts`, on this seam rather than inherited from
-`publish`'s suite by analogy.
-
-**4xx is not swept**, which is the other half of the fix: the
-repeated-`?version=` refusal is checked before `delete` is called at all,
-`PACKAGE_DELETE_PARTIAL` keeps its 400 (per-item uninstall failures are a
-different outcome), a declared 4xx thrown from below keeps its own status and
-code, and a declared 5xx keeps its own too.
-
-**No message changed, and that is deliberate.** Unlike `publish`, this path
-never disclosed anything: the door builds its sentence from the request's own
-`:id` and `?version=`, and the producer returns a bare flag with **no message
-channel at all**. Mirroring `publish`'s `driverFault` message here for symmetry
-would have *created* a channel to the wire that nothing filters — the 5xx
-withhold (#8086) lives in `sendThrownError`, which a returned failure never
-reaches at any status. The new suites pin that absence from both sides: the
-producer's returned shape has exactly one key, and the door answers its own
-sentence even when handed a producer that grows a message.
-
-Verified against a real `node:sqlite` database running the real statements from
-`index.ts` — including a genuine foreign-key restriction, the fault family only
-`DELETE` can have.
diff --git a/.changeset/package-routes-getmetaitems-spec-types.md b/.changeset/package-routes-getmetaitems-spec-types.md
deleted file mode 100644
index 01ae2b89bd..0000000000
--- a/.changeset/package-routes-getmetaitems-spec-types.md
+++ /dev/null
@@ -1,52 +0,0 @@
----
-"@objectstack/rest": patch
----
-
-refactor(rest): `package-routes`' `protocol.getMetaItems` option reads the spec's declared request/response instead of a hand-rolled local shape (#9846)
-
-`PackageRoutesOptions.protocol` declared its meta-read verb as a local
-structural type — `getMetaItems?(req: { type: string }): Promise<{ items: any[] }>`
-— rather than naming the shapes `packages/spec` already declares. Nothing was
-broken by it: both call sites send exactly `{ type: 'package' }`, which is a
-valid `GetMetaItemsRequest`, and both read `result?.items` defensively.
-
-What it was, is the same blindness class one level up from the sibling
-meta-read doors: a request type *re-stated locally* rather than *read from the
-spec* lets the contract move underneath this module — a narrowed `type`
-vocabulary, a newly required member, a renamed key — while the file keeps
-compiling green against a shape the protocol no longer has.
-
-Both are now sourced from `@objectstack/spec/api`:
-
-```ts
-getMetaItems?(req: GetMetaItemsRequest): Promise;
-```
-
-**The optionality and the runtime feature-detection are deliberately kept.**
-`MetadataProtocol` declares `getMetaItems` as a **required** member, while this
-option is optional and both call sites guard with
-`typeof … === 'function'`. Adopting `MetadataProtocol` whole would change what
-the seam tolerates — a behaviour question, deliberately not answered here.
-
-Naming the declared response surfaced one thing the local `any[]` had been
-hiding: the spec types `items` as `unknown[]`, because it says nothing about
-what a metadata item *contains*. The registry-specific keys this module reads
-off each entry (`manifest.id`) are not spec-declared, so the **element** read
-stays runtime-shaped on purpose — the same disposition the sibling doors take
-via `metaItemsArray`. The seam is typed; the element read is coerced at the
-read and unchanged in behaviour.
-
-A compile-time pin holds the coupling: an exact type-equality assertion that
-the option's request/response types are still the spec's, so re-hand-rolling
-the local shape fails the build rather than passing unnoticed. It lives in
-compiled source rather than a test file, because this package's `tsconfig.json`
-excludes its test files and no sibling gate type-checks them — a type-level
-assertion written there would be compiled by nothing.
-
-`deletePackage`'s local structural type is untouched: no declared spec shape
-exists for that verb, and minting one is a contract act rather than a typing
-cleanup.
-
-Internal typing only — `PackageRoutesOptions` is not exported from the
-package's entrypoint, so no public surface changes and no route changes what it
-accepts or rejects.
diff --git a/.changeset/partial-field-masking.md b/.changeset/partial-field-masking.md
deleted file mode 100644
index e86732d117..0000000000
--- a/.changeset/partial-field-masking.md
+++ /dev/null
@@ -1,24 +0,0 @@
----
-'@objectstack/spec': minor
-'@objectstack/plugin-security': minor
----
-
-Partial field masking (#8993): `FieldSchema` declares `maskingRule` — a closed
-preset enum (`phone`, `id_card`, `bank_account`, `email`, `name`) plus a
-`{ keepHead, keepTail }` escape hatch — and plugin-security's `FieldMasker`
-enforces it in the same PR (ADR-0049 declare = enforce; the key re-enters the
-schema only with its runtime consumer attached, honouring the 2026-06 prune in
-spirit).
-
-A field declaring a rule is served masked-but-recognisable (`138****5678`) to
-every non-system caller; the field's `requiredPermissions` (ADR-0066 D3) is the
-unmask gate — holders of all listed capabilities read the full value. A
-permission set that marks the field non-readable still deletes it entirely.
-Masking rides the single runtime channel, so API callers, browser users, the
-CSV/XLSX export route and the AI-context interceptor all see the same
-deterministic, length-preserving masked value. Masked callers cannot filter,
-sort, group or aggregate on the field (403, the FLS predicate-oracle guard),
-and a write that round-trips a masked placeholder is refused with
-`400 VALIDATION_ERROR` instead of silently overwriting the stored value.
-New exports: `FieldMaskingRuleSchema`, `FieldMaskingKeepSchema`,
-`FIELD_MASKING_PRESETS`, `maskFieldValue`, `MASK_CHAR`.
diff --git a/.changeset/per-organization-audience-binding-suggestions.md b/.changeset/per-organization-audience-binding-suggestions.md
deleted file mode 100644
index 2175c57951..0000000000
--- a/.changeset/per-organization-audience-binding-suggestions.md
+++ /dev/null
@@ -1,29 +0,0 @@
----
-"@objectstack/plugin-security": patch
----
-
-Reconcile audience-binding suggestions per organization (ADR-0090 D5/D9)
-
-`sys_audience_binding_suggestion` rows are per-tenant by construction — a
-package suggests, and a TENANT admin confirms — but the reconciler read and
-wrote through a module-level `{ isSystem: true }` context carrying no tenant.
-On a shared-runtime multi-organization installation that produced ONE
-organization-less row that every tenant read: the first admin to confirm or
-dismiss answered for all of them, while the binding their confirm created
-existed only in their own organization, so every other tenant's users never
-received the package's default permission set and the surface reported the
-suggestion resolved.
-
-- every read and write in the module now carries `{ isSystem: true, tenantId }`
- — the anchor lookup, the "is it already bound?" lookup, and the
- list/confirm/dismiss paths, not just the writes;
-- `reconcileAudienceBindingSuggestions` is the new entry point the runtime
- calls: one pass per organization under a `group`/`isolated` posture, and the
- publishing organization alone on the package-door publish path;
-- pre-existing organization-less rows are reaped before the passes and
- regenerated per organization. Without that, ADR-0120 D3's platform bucket
- keeps showing the old row to every tenant and the per-organization passes
- create nothing at all. No permission binding is touched by the reap.
-
-A `single`-posture deployment is unchanged: exactly one organization-less pass,
-and no reap.
diff --git a/.changeset/phantom-anchor-write-deny-diagnostic.md b/.changeset/phantom-anchor-write-deny-diagnostic.md
deleted file mode 100644
index 9e5c6bb4c5..0000000000
--- a/.changeset/phantom-anchor-write-deny-diagnostic.md
+++ /dev/null
@@ -1,19 +0,0 @@
----
-'@objectstack/plugin-sharing': minor
----
-
-A write refused because a federated object's `owner_id` is the platform's phantom anchor now says so, once per object (#8418).
-
-**No verdict changes.** `checkEdit` / `checkDelete` stay fail-closed exactly as shipped — this adds a diagnostic and nothing else. Maintainer ruling 2026-08-13 (option C on #8418): keep `deny`, make the refusal visible.
-
-What was wrong: on an ADR-0015 federated object with no author-declared `owner_id`, the registry injects the anchor but the platform provisions no column behind it, so the ownership fast path selects `owner_id` off a remote table that does not have it. The SQL driver's recovery ladder DISCARDS a projection naming an unresolvable column and re-runs `select('*')` instead of raising — so `matchesOwnerScope` receives a good row that simply has no `owner_id` key, reads `owner == null`, and refuses. Because nothing threw, `writeGateFailClosed` was never reached and **nothing was logged anywhere**: the operator got a bare 403 with no trace, at every write depth (`org` included — the null-owner short-circuit runs before the scope is consulted). Only a `modifyAllRecords` holder could still write.
-
-`SharingService` now emits `PHANTOM_ANCHOR_WRITE_DENY_NOTICE` at `warn` on that path, naming the object, the owner field and the caller, with both remedies in the wording: declare the real remote owner column, or move the object off an owner-scoped sharing model. The constant is exported so a deployment can match on it.
-
-Deduped **per object**, for the service's lifetime. The condition is a property of the registered schema, identical for every row and every caller, so a bulk write emits one line rather than one per row and one misconfiguration is not multiplied by the principal count.
-
-It fires only on the phantom anchor, never on an ordinary owner-less row: the discrimination is `hasPhantomOwnerAnchor` provenance (is this `owner_id` the platform's injected constant, or a column the author declared?), not `owner == null` and not an `external` test. A federated object with a real declared remote owner column keeps scoping normally and stays silent.
-
-The diagnostic cannot cost a write — it returns `void`, its caller ignores it, and a throwing logger is swallowed, so no ordering of schema lookup, latch and logger can move a verdict.
-
-Also corrected in passing: this package attributed the driver's non-throwing unknown-column recovery to **SQLite specifically**. That understated it — the projection rung is gated by the driver's single shared `isUnresolvableColumnError` predicate, which spells all three dialects it speaks (`no such column`, `column … does not exist`, and since #8926 `Unknown column '…'`), so the silent refusal reproduced on every supported dialect. Wording only; no driver change.
diff --git a/.changeset/platform-default-permission-sets-platform-owned.md b/.changeset/platform-default-permission-sets-platform-owned.md
deleted file mode 100644
index d1e90d59b5..0000000000
--- a/.changeset/platform-default-permission-sets-platform-owned.md
+++ /dev/null
@@ -1,64 +0,0 @@
----
-"@objectstack/plugin-security": patch
----
-
-fix(security): platform default permission sets are stamped `managed_by: 'platform'`, so `os meta resync` stops skipping every one of them (#8692)
-
-
-
-`bootstrapPlatformAdmin` seeded the default permission sets
-(`admin_full_access` / `member_default` / `viewer_readonly` …) **without writing
-`managed_by`**, so the value fell to the declared `defaultValue: 'admin'` on
-`sys_permission_set`. `os meta resync` only reconciles rows the platform still
-owns (`managed_by` absent or `'platform'`), so the platform's own default sets
-took the skip branch — **measured on a real engine: `resynced 0` /
-`resyncSkipped 8`, every shipped set**, each one logged as an *"intentional
-override"* for a row no admin had ever touched.
-
-That is the exact inverse of what the resync flag was built for (#2705:
-*"reconcile the row to the shipped dist so a dev source edit takes effect
-without `--fresh`"*). The command could not perform, for the rows it names in
-its own help text, the one job it exists to do.
-
-**The seed insert now stamps `managed_by: 'platform'` explicitly**, which also
-puts this seeder in line with its two siblings in the same package —
-`bootstrap-builtin-positions.ts` and `bootstrap-system-capabilities.ts` both
-stamp `'platform'` rather than inheriting a default. A fresh install's default
-sets are now platform-owned, and a resync reconciles all of them. Admin-takeover
-protection is unchanged in shape and becomes *real* rather than nominal: a set
-an admin takes over in Setup is stamped `'admin'` by the projection path, so
-platform-seeded and admin-authored rows finally carry **different** values
-instead of the same one.
-
-**Forward-stamp only — existing rows are deliberately NOT migrated.** A stored
-`'admin'` is indistinguishable between "the old seeder's field default" and "an
-administrator took this set over in Setup". Restamping legacy rows to
-`'platform'` would make genuine admin customizations reconcilable and could
-silently overwrite them on the next `os meta resync`, so pre-existing rows keep
-the skip permanently and by decision. Report, don't rewrite. A legacy install
-that wants its platform defaults reconciled has to re-own the rows deliberately
-(or re-seed with `--fresh`) — an operator's choice, not one a boot makes for
-them. The seeder's docblock records this so the next reader finds a decision
-rather than a mystery.
-
-**The skip warning stops claiming intent.** It read
-`… row is admin-owned (intentional override)`; on any pre-existing install that
-sentence is false, because the only writer may have been this same seeder one
-call earlier. It now reads `… row is admin-owned` — provenance and action, no
-claim about anybody's intent.
-
-Two comments asserting that the insert-once posture *"keeps the platform
-defaults env-authored — the posture `bootstrapDeclaredPermissions` relies on"*
-are removed: that reliance was measured false. `bootstrapDeclaredPermissions`
-special-cases only `managed_by === 'package'`; every other value — `'platform'`
-included — falls to the same `skippedEnvAuthored` branch, so its behaviour is
-identical before and after this change.
-
-The pin suite added by the measurement round now asserts both sides of the line
-the ruling drew: a fresh install stores `'platform'` and resyncs everything, and
-a pre-ruling `'admin'` row is still skipped with its content intact.
diff --git a/.changeset/plugin-audit-readme-audit-service-link.md b/.changeset/plugin-audit-readme-audit-service-link.md
deleted file mode 100644
index ffd972ea44..0000000000
--- a/.changeset/plugin-audit-readme-audit-service-link.md
+++ /dev/null
@@ -1,48 +0,0 @@
----
-"@objectstack/plugin-audit": patch
----
-
-Published README points at the `services.audit` reference again, in the form a published reader can follow (#9589)
-
-PR #9531 dropped this README's "See Also" pointer to the runtime-services audit
-page because the page was measured wrong — it documented `record()` /
-`'set' | 'reset'` (the settings sink) as if that were the `audit` slot. PR #9587
-rewrote the page around the real slot, so the reason for the omission has stopped
-holding, and the link is restored.
-
-It is restored because the page carries three things this README deliberately
-does not, each verified against the page as it stands on `main` rather than
-against the PR title that rewrote it:
-
-- **the failure posture of the slot itself** — `recordAuthEvent` never throws; a
- failed ledger insert is reported at `error` level once per process and then
- drops to `debug`, the row is lost and nothing retries it, and the call silently
- no-ops when no data engine resolves or when `userId` is absent. This README
- documents the *record-view batcher's* two failure postures, which are a
- different code path; it says nothing about this one.
-- **the event's field-by-field shape** — that `userId` must be a real `sys_user`
- id, that `sessionId` lands on `record_id` with `object_name` fixed to
- `sys_session`, that `organizationId` stamps the tenant columns and an unstamped
- row is one non-administrator members can never see, and that `context` is
- serialized into `metadata`. This README states the slot's interface and its
- closed `'login' | 'logout'` action union, and deliberately stops there.
-- **the settings-sink disambiguation** — that `SettingsAuditSink.record()` is
- never registered as or resolved from this slot, and that
- `getService('audit').record({ ... })` therefore fails with a `TypeError`.
-
-The restored line is **not** the line #9531 removed. That one read
-`[Audit Logging Best Practices](/content/docs/kernel/runtime-services/audit-service.mdx)`
-— a label describing a best-practices guide the page has never been, and a
-repo-path-rooted URL that resolves for neither of this README's published
-audiences. A README in the package's `files` array is rendered on npm and on
-GitHub, where a root-relative href resolves against `npmjs.com` / `github.com`,
-not against the docs site. The replacement uses the absolute
-`https://docs.objectstack.ai/docs/...` form that `create-objectstack`'s published
-READMEs already use, and its annotation states what the page adds — so the next
-author weighing the same omission can check the justification instead of
-reconstructing it.
-
-The one pre-existing site-root-relative docs link in this same file
-(`/docs/permissions/permission-sets#access-depth...`, added by the same PR) is
-converted to the same absolute form. Its target page and heading anchor both
-exist; only the spelling was unfollowable off the docs site.
diff --git a/.changeset/plugin-audit-readme-published-claims.md b/.changeset/plugin-audit-readme-published-claims.md
deleted file mode 100644
index 1dc7edcf17..0000000000
--- a/.changeset/plugin-audit-readme-published-claims.md
+++ /dev/null
@@ -1,80 +0,0 @@
----
-"@objectstack/plugin-audit": patch
----
-
-docs(plugin-audit): the published README stops documenting an `auditService` API, a row shape and an action vocabulary that do not exist (#9517)
-
-
-
-`packages/plugins/plugin-audit/README.md` is in the package's published `files`
-array and `private` is unset, so it is **what the npm package page renders**. It
-documented an API surface with no implementation anywhere in the repo, under a
-banner claiming SOC 2 / HIPAA / GDPR readiness.
-
-**Measured against `origin/main` before anything was rewritten**, and the drift
-was wider than the ledger of it:
-
-- **Every `auditService.*` method the README called is absent from the repo** —
- `getFailedActions`, `logAdminAction`, `logDataAccess`, and also
- `getRecordHistory`, `getUserActivity`, `searchLogs`, `getRecordSnapshot`,
- `generateReport`, `archiveLogs`, `purgeLogs`, `logDataDeletion`,
- `logDataExport`. Twelve methods, zero implementations. A reader following the
- README wrote code that could not compile.
-- **`PluginAudit` does not exist**, and neither does the `.configure({...})`
- static it was called through — no class in this repo exposes one. The export is
- `AuditPlugin`, a `Plugin` class registered as `kernel.use(new AuditPlugin())`
- and taking **no configuration at all**. The documented config object
- (`trackObjects`, `trackFields`, `retentionDays`, `autoArchive`, `excludeUsers`,
- `trackSystemEvents`) was fabricated in full.
-- **`IAuditService` is not in `@objectstack/spec/contracts`** — the README's
- "Contract Implementation" section named an interface the spec has never
- declared.
-- **The row shape was not the shipped one.** The README declared `timestamp`,
- `userName`, `userEmail`, `recordName`, `changes`, `sessionId`, `status` and
- `errorMessage`. `sys_audit_log` declares none of them.
-- **The action values were outside the enum.** `'insert'`, `'auth:login'`,
- `'security:password_reset'`, `'workflow:approval'` and `'user_role_change'` are
- not forms this object accepts; the namespaced-colon spelling never was one.
-- **The object name was wrong** — `audit_log`, not `sys_audit_log`.
-- **The REST namespace does not exist.** Six `/api/v1/audit/*` routes were
- documented; the object declares `apiMethods: ['get', 'list']` and is read over
- the ordinary object API.
-
-The compliance paragraph is **deleted, not softened or relocated**: a
-regulatory-readiness claim is a company-level statement needing an accountable
-owner, and it does not belong in a package README. The three external
-SOC 2 / GDPR / HIPAA links that existed only to support that framing are gone
-with it.
-
-The replacement documents only what the code can be pointed at: the real exports;
-the real `sys_audit_log` columns; the seven-value action enum **with the writer
-for each value**, so a reader can check any row of it; the credential masking on
-`old_value` / `new_value`; and the coverage model, which is
-**all objects minus an exclusion list** rather than the fabricated per-object
-`trackObjects` config — subtraction, because the object universe is open and an
-enumerated allow list would silently stop auditing everything registered after
-boot.
-
-Three things are now stated that the old README obscured, all of them gaps a
-reader could otherwise mistake for coverage:
-
-- **reads and views are not on the ledger** — no writer emits a read action;
-- **failed operations are not on the ledger** — there is no success/failure
- column, and the writers fire only on `after*` events, i.e. only on operations
- that succeeded, so `getFailedActions`-style "security monitoring" had no
- mechanism behind it in the first place;
-- **`ip_address` / `user_agent` are populated on auth events only** — the
- record-level writer does not stamp them, so a null client fingerprint on a CRUD
- row does not mean the request had none.
-
-Two dependency boundaries are **named with their degraded behaviour** rather than
-left silent, following the `access-recipes.mdx` pattern: hierarchy-relative
-permission scopes need `@objectstack/security-enterprise` and **fail closed to
-`own`** without it, so a grant written to let managers read their reports' audit
-rows shows them only their own on an open build; and `lifecycle.archive` needs a
-registered `archive` datasource, **failing closed to retention** without one —
-nothing is ever deleted and the table grows, which is the safe direction for a
-ledger but not the documented one.
diff --git a/.changeset/plugin-route-envelope-conformance.md b/.changeset/plugin-route-envelope-conformance.md
deleted file mode 100644
index dd7b6297ef..0000000000
--- a/.changeset/plugin-route-envelope-conformance.md
+++ /dev/null
@@ -1,30 +0,0 @@
----
-"@objectstack/cloud-connection": patch
----
-
-Cloud-connection refusals now emit the response envelope they declare.
-
-Eleven error exits on `/api/v1/cloud-connection/*` answered with
-`error: { code }` and no `message`. `ApiErrorSchema.message` is REQUIRED, so
-`body.error.message` read `undefined` on the wire for every one of them — the
-Console had already grown the accommodation that produces, displaying
-`body?.error?.message ?? body?.error?.code` and so showing a machine code to a
-human. All eleven now carry a readable message; no status and no code changed.
-
-`POST /api/v1/cloud-connection/bind/poll` additionally stamped the UPSTREAM
-RFC 8628 spelling (`expired_token`, `access_denied`, …) straight into
-`error.code`, which is a closed ADR-0112 vocabulary — so that body failed its
-own contract. The wire change, for anyone branching on it:
-
- before: { success: false, data: { pending: false },
- error: { code: "expired_token" } }
- after: { success: false, data: { pending: false },
- error: { code: "DEVICE_CODE_FAILED",
- declaredCode: "expired_token",
- message: "Device authorization failed: expired_token" } }
-
-Nothing is lost: the verbatim upstream spelling now rides `declaredCode`, the
-open producer-authored channel ADR-0112 declares for a code the serving side's
-ledger does not know. Read `error.declaredCode` where you previously read
-`error.code` for the RFC 8628 value; `error.code` is now the registered member,
-which is what a consumer branching on platform conditions should key on.
diff --git a/.changeset/plugin-route-refusals-enveloped.md b/.changeset/plugin-route-refusals-enveloped.md
deleted file mode 100644
index 379a833d43..0000000000
--- a/.changeset/plugin-route-refusals-enveloped.md
+++ /dev/null
@@ -1,72 +0,0 @@
----
-"@objectstack/plugin-hono-server": minor
-"@objectstack/hono": minor
-"@objectstack/cli": minor
----
-
-fix(api): the plugin-mounted Hono error paths answer the declared envelope — six refusal bodies stop speaking the pre-#3675 dialect (#9364)
-
-Six hand-built refusal bodies on plugin-mounted Hono routes departed from
-`BaseResponseSchema`. They were invisible to every check in the repo until
-#9267 added the gate's third surface, which discovers these routes by parsing
-rather than by filename. This converts the **error-path** half of what that
-first run measured; the bare pre-auth discovery payloads it also found are a
-separate wire ruling (#9389) and are untouched here.
-
-**If you branch on these bodies, this is the change.** Every one of them was
-readable only by reaching for a key the contract does not declare, so no
-consumer that followed `ApiErrorSchema` was reading them successfully in the
-first place — `body.error.message` read `undefined` on all six.
-
-`@objectstack/plugin-hono-server` — the adapter's own refusals, the answer any
-host using it as its transport gets for an unmatched request or a handler that
-produced nothing:
-
-| status | was | now |
-|:--|:--|:--|
-| 404 unmatched path | `{ error: 'Not found' }` | `{ success: false, error: { code: 'ENDPOINT_NOT_FOUND', message: 'Not found' } }` |
-| 405 method mismatch | `{ error, code, message, method, path, allowed }` | `{ success: false, error: { code: 'METHOD_NOT_ALLOWED', message, details: { method, path, allowed } } }` |
-| 500 handler wrote nothing | `{ error: 'No response from handler' }` | `{ success: false, error: { code: 'INTERNAL_ERROR', message: 'No response from handler' } }` |
-| 500 fallback threw | `{ error: 'Fallback handler failed' }` | `{ success: false, error: { code: 'INTERNAL_ERROR', message: 'Fallback handler failed' } }` |
-
-The 405 is the sharpest of the four: it already carried a real semantic code,
-but placed it BESIDE `error` rather than inside it, so `body.error.code` read
-`undefined` while `body.code` worked — the #7035 dialect. Its `code` **value**
-is unchanged (`METHOD_NOT_ALLOWED`, a `StandardErrorCode` member); only its
-position moved, along with the three context keys, which are now
-`error.details` — the slot `ApiErrorSchema` declares for exactly that. The
-`Allow` header is unchanged and remains the primary channel for it.
-
-`@objectstack/hono` — the shared `errorJson` helper wrote the HTTP **status**
-into `error.code`, so every refusal from this mount shipped `error.code: 404`
-or `500` where `ApiErrorSchema.code` declares a closed STRING vocabulary
-(ADR-0112 D3/D4). It now derives the standard member for the status through
-`resolveThrownHttpError` (`@objectstack/types`) — the one rule the REST and
-dispatcher doors already read for this question, so this third door does not
-become a fourth dialect. A 404 from this mount now carries
-`error.code: 'RESOURCE_NOT_FOUND'`; the numeric status stays where it is
-authoritative, on the response line.
-
-`@objectstack/cli` — the unbound-hostname 404 from `os serve`'s
-`OS_ROOT_DOMAIN` guard answered
-`{ error: 'environment_not_found', message, hostname }`: a bare-string error
-with two stray top-level keys, and a lowercase code where error codes are
-`SCREAMING_SNAKE`. It is now
-`{ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message, details: { hostname } } }`.
-The `Accept: text/html` branch still serves the styled 404 page, unchanged.
-
-**The cross-adapter reference implementation moved with it.**
-`@objectstack/http-conformance`'s zero-dependency `NodeHttpServer` mirrors the
-adapter's unmatched-request bodies byte-for-byte on purpose — the whole point
-of that package is proving the transport port is free of framework-isms, and
-`fallback-seam.conformance.test.ts` runs the same cases against both. Leaving
-it behind would have made "both adapters agree" false in the suite that exists
-to assert it.
-
-Every converted body is judged by `scripts/check-route-envelope.mjs`, whose
-per-file counters for these three modules go to zero and are banked as
-conformant. The literals are deliberately written INLINE at each `c.json(...)`
-call rather than hoisted into shared constants: the gate reads the object
-literal, and an identifier reads to it as a relayed body it must not police —
-hoisting would have zeroed the counters by hiding the bodies from the scanner
-instead of by conforming them.
diff --git a/.changeset/plump-crabs-sneeze.md b/.changeset/plump-crabs-sneeze.md
deleted file mode 100644
index 7adb0b1016..0000000000
--- a/.changeset/plump-crabs-sneeze.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-'@objectstack/plugin-security': patch
----
-
-fix(security): the derived capability seeder owns its row by the same conjunction as the curated half
-
-`bootstrapSystemCapabilities`' DERIVED half tested ownership with `managed_by === 'platform'` alone. That was sufficient while `sys_capability.name` was unique installation-wide; since #8461 made it unique per ORGANIZATION (ADR-0120 D1) it also admits a platform-STAMPED row sitting inside an organization — the shape the file header names ("from seed data or a legacy import") and the shape #8470 refused to let `managed_by` alone stand for on the curated half, because it "would not carry that guarantee". The guard admitted such a row and rewrote its `label`/`description` with `humanize(name)`, which is the precise harm #5876 exists to prevent, while the platform (NULL-organization) bucket was never written. Every counter read zero and nothing was logged, because both #5876's counter and #8536's live on the branch where the guard DECLINES.
-
-The ownership test is now the same conjunction the curated half uses — `managed_by: 'platform'` AND `organization_id: null`. The lookup is unchanged (still cross-organization, by design). This restores a declared invariant rather than widening an accept set: what the derived half may refresh narrows to the rows it provably owns.
-
-**Reachability: a DORMANT asymmetry with a LIVE route — not a live defect.** No shipped artifact in this repository produces such a row: both capability seeders run under a system context with no tenant and never write `organization_id`, `normalizeManagedByVocab` does not touch this object, the admin door refuses the stamp outright (`assertSystemRowWriteGate`), and no `sys_capability` seed dataset exists anywhere in the repo. The ROUTE is nevertheless live and needs no unsupported step, and its load-bearing link is measured rather than argued: the seed loader writes as `isSystem` specifically so seeds can target `sys_*` tables, `defineSeed` type-checks `managed_by: 'platform'`, and on a per-organization replay the loader's tenant stamp short-circuits its own `sys_` exemption when an organization is pinned. Measured against the real seed loader, a `sys_capability` seed carrying `managed_by: 'platform'` was inserted with `organization_id` set when an organization was pinned, and inserted unstamped when none was — so the stamp is the pinning's doing, not a fixture artifact. Not claimed: how many organizations a given deployment replays seeds into is a provisioning question this repo cannot answer. So the fix lands as trap-removal and invariant-restoration, at exactly that severity — worth landing because the mistake would be invisible, ADR-0066 asset ownership forbidding the organization's own admin from editing or deleting the row through Setup.
-
-**Observability.** The newly-declined row flows through #8536's skip branch unchanged, so `skippedAuthored` and `unseededDerived` keep their exact documented meanings and their subset relationship; they simply become reachable on a state the broken guard used to swallow. The misplaced stamp gets its OWN signal, a new `platformStampedInOrg` counter on `CapabilitySeedResult`, rather than being folded into `unseededDerived` — "the platform's definition is missing" and "a row wears the platform's stamp where the platform never writes" are different facts, and the second is worth counting even when the first is false. The warning gains a matching remediation arm; the admin-authored row's "supported extension" sentence would be false here, and its "nothing for an operator to remove" advice would be wrong about the one row Setup cannot touch at all.
-
-**Not changed:** the platform bucket is still not backfilled when another row satisfies the lookup. That is #8552's ruled posture (no adoption, no backfill), shipped for the admin-authored case in #8536; the fix makes the state observable, not repaired, and the suite pins the bucket ABSENT so a future backfill has to fail rather than pass.
-
-`patch`, not `minor`: the behaviour change is a guard declining a row it should never have rewritten, plus diagnostics. `platformStampedInOrg` is a new field on a returned result object, but `bootstrapSystemCapabilities` is a boot-time internal whose only caller ignores the result shape — no consumer reads the type, so nothing gains a capability it can build on.
diff --git a/.changeset/postgres-dsn-bound-secret-reaches-server.md b/.changeset/postgres-dsn-bound-secret-reaches-server.md
deleted file mode 100644
index bebd80a09f..0000000000
--- a/.changeset/postgres-dsn-bound-secret-reaches-server.md
+++ /dev/null
@@ -1,74 +0,0 @@
----
-"@objectstack/service-datasource": patch
----
-
-fix(security): a bound `external.credentialsRef` reaches the postgres SERVER on the DSN branch, not just the knex config (#8873)
-
-A postgres datasource whose `config.url` is a DSN and whose credential is bound
-through `external.credentialsRef` (or the connection form's secret field) opened
-its connection **with no password at all**. Not a disclosure — a broken binding,
-of the fail-quietly kind: `DatasourceConnectionService` resolved the secret
-fail-closed, the operator saw a bound credential and a datasource reporting
-connected, and the handshake carried nothing.
-
-**This arm was the one that looked correct.** It had an explicit secret branch
-and a comment declaring the intent — *"For a DSN, a separately-supplied secret
-overrides the embedded password"* — and it emitted
-`{ connectionString: url, password: spec.secret }`, which passes any assertion
-written against the factory's own output. `pg` discarded the credential one
-layer lower:
-
-```js
-// pg 8.22.0, lib/connection-parameters.js
-if (config.connectionString) {
- config = Object.assign({}, config, parse(config.connectionString))
-}
-```
-
-Two independent mechanisms destroyed it, either sufficient on its own. `parse()`
-emits a `password` key for **every** url — `''` when the url carries no userinfo
-password — and `Object.assign` copies that over the injected value, after which
-`val('password', …)` falls through to `PGPASSWORD` and the defaults; and knex's
-`setHiddenProperty` has already made `password` a non-enumerable own property of
-`connectionSettings`, which `Object.assign` does not copy at all. Measured on pg
-8.22.0 + knex 3.3.0: `postgresql://app@db.internal:5432/app` with a secret bound
-resolved to password `null`, and a stored pre-#8082 url embedding
-`app:embedded-legacy@` resolved to `'embedded-legacy'` — the DSN beating the
-credential an operator deliberately bound. Since #8082 refuses a
-`user:password@` userinfo at the publish door, the credential-free DSN is the
-only authorable URL shape for this driver, so this was the shape the connection
-form produces.
-
-**The remedy is a third shape, not either sibling's.** The clients merge a DSN
-against explicit keys in opposite directions: `mysql2` lets the explicit key win
-(`{ uri, password }`, #8875) and mongodb rides in `options.auth` beside an
-untouched url (#9042), while `pg` lets the DSN win. So on the postgres DSN
-branch — and only when a secret is bound — `connectionString` is gone: the arm
-hands `pg` **pg's own parse of the url** (`pg-connection-string`, the client's
-parser, so there is no second dialect of `postgresql://…` in this repo to drift
-out of agreement) with the credential applied afterwards, where nothing
-re-parses over it. Everything else resolves exactly as before, verified
-key-by-key across the sslmode, unix-socket, `?options=`, credential-free,
-embedded-password and no-userinfo forms.
-
-The competing remedy — keep `connectionString` and splice the secret into the
-userinfo — was measured and rejected on two counts: `pg-connection-string`
-honours a `?password=` query parameter **over** userinfo, so a stored pre-#8337
-row would still lose the bound secret; and it would materialise the cleartext
-credential into a string nothing hides (`JSON.stringify` of knex's
-`connectionSettings` prints the whole DSN, while a discrete `password` stays
-hidden), re-creating at connect time the hardest-to-redact credential spelling
-that #8082 refuses to let anyone author.
-
-**What changes for an existing deployment.** A DSN datasource that binds no
-secret is byte-for-byte unaffected — it still hands `pg` the url unparsed. One
-behaviour worth knowing: a stored pre-#8082 row that embeds a password in its
-url *and* binds a credential now authenticates with the **bound** credential,
-which is the precedence this arm's own comment always claimed and both sibling
-arms already apply. A DSN naming no user still receives the credential (unlike
-the mongodb arm's deliberate no-op there): `pg` sends a password only when the
-server asks for one, so injecting cannot break a datasource that connects today.
-Finally, a url `pg`'s own parser rejects (a multi-host DSN, which node-postgres
-does not implement) is now refused when the driver is built rather than on first
-query — the same error, named and located, with the url deliberately not echoed
-because it may itself embed a credential.
diff --git a/.changeset/preflight-refusal-audit-row.md b/.changeset/preflight-refusal-audit-row.md
deleted file mode 100644
index b9aa1afee3..0000000000
--- a/.changeset/preflight-refusal-audit-row.md
+++ /dev/null
@@ -1,29 +0,0 @@
----
-"@objectstack/metadata-protocol": patch
-"@objectstack/metadata-core": patch
----
-
-fix(metadata-protocol): a package publish refused by the namespace-prefix rule now leaves an audit row per violation (#8595)
-
-`publishPackageDrafts` refuses a whole batch pre-flight when an object draft's
-name is missing its package namespace prefix (ADR-0028). That refusal returns
-ABOVE the batch's `engine.transaction()`, so it reached neither the post-commit
-`allowed` rows nor the rollback handler's `batch_aborted` row: it wrote nothing
-to `sys_metadata_audit` at all. The compliance consequence is the defect — a
-package rejected for a bad object name was **indistinguishable in the trail from
-a package nobody ever pressed Publish on**, so a compliance query could not tell
-a refused publish from one that never happened.
-
-Each violation now leaves its own `publish` / `denied` row keyed on the
-offending draft's `(type, name)` — the tuple `auditMetaItem` reads, so the
-refusal is visible on that item's own audit-log tab via
-`GET /api/v1/meta/:type/:name/audit`. The row carries the violated rule
-(`namespace_prefix`) as its `code`, and the rule's actionable message as `note`.
-Rows are keyed on the draft's own organization scope, matching the promoted
-rows: an env-wide draft audits env-wide even when the publishing session carries
-an active org.
-
-One row per violation rather than one per batch: a pre-flight refusal names N
-violating items and no single causal one, so a batch-level row would have had to
-mint a synthetic identity — exactly what the `batch_aborted` row declines to do
-for its own unattributable case.
diff --git a/.changeset/preview-drafts-state-declared.md b/.changeset/preview-drafts-state-declared.md
deleted file mode 100644
index e0108a8943..0000000000
--- a/.changeset/preview-drafts-state-declared.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-"@objectstack/spec": minor
-"@objectstack/rest": patch
----
-
-Declare the draft-visibility switches on the meta-read request schemas, exactly where the implementation enforces them (#9741, maintainer ruling 2026-08-18): `GetMetaItemsRequestSchema` gains `previewDrafts?: boolean`, and `GetMetaItemRequestSchema` gains `state?: 'active' | 'draft'` plus `previewDrafts?: boolean`. Both members are draft-visibility switches only — declaration ≠ authorization: ADR-0106 masking is unaffected, and draft access stays admin-gated upstream. The cached and layered read requests deliberately declare neither (their implementations enforce neither). `environmentId` stays OUT of the protocol request shape by explicit ruling — it is the transport-level multi-kernel routing key, recorded schema-side as a decision rather than an omission. The REST meta-read doors (list, cached and uncached single-item, layered) drop their `as any` request casts: each request literal now compiles against the declared spec shape, with the transport-level `environmentId` carried by a typed transport envelope (`TransportScopedMetaRequest`) instead of a cast. Accept-set widening catch-up on the declared surface; zero runtime behaviour change.
diff --git a/.changeset/probe-mcp-serveable-shared-entry-point.md b/.changeset/probe-mcp-serveable-shared-entry-point.md
deleted file mode 100644
index b0a2cba31d..0000000000
--- a/.changeset/probe-mcp-serveable-shared-entry-point.md
+++ /dev/null
@@ -1,50 +0,0 @@
----
-"@objectstack/rest": patch
----
-
-fix(rest): `/discovery`'s `mcp` advertisement follows the request's environment — `probeMcpServeable` routes through the shared resolution entry point (#9120)
-
-`RestServer.resolveRequestEnvironmentId` calls itself, in its own doc-comment,
-"THE single entry point for every unscoped-route environment decision (protocol,
-i18n, exec-ctx, analytics, …) so they can never disagree about which kernel a
-request belongs to." Eight consumers go through it. `probeMcpServeable` — the
-ninth site that needs the request's environment, and the one whose answer decides
-whether `/discovery` advertises `routes.mcp` — re-derived its own:
-
-```ts
-let environmentId: string | undefined = req?.params?.environmentId;
-if ((!environmentId || environmentId === ':environmentId') && this.defaultEnvironmentIdProvider) {
- try { environmentId = this.defaultEnvironmentIdProvider() || undefined; } catch { /* ignore */ }
-}
-```
-
-That is the shared chain minus its first and middle steps: the host's ADR-0006
-`kernel-resolver` seam (wired through `RestRequestEnvResolver`), and the legacy
-hostname / `X-Environment-Id` chain beneath it.
-
-**Single-environment boots were correct throughout** — there
-`defaultEnvironmentIdProvider` is registered, and it is also step 3 of the shared
-chain, so both spellings agreed. The defect is multi-tenant-only: on a
-hostname-routed host an unscoped `/discovery` request carries no
-`params.environmentId`, and no default provider is registered (that is
-`createSingleEnvironmentPlugin`'s wiring). Neither input the probe read was
-present, so it fell through to `serviceExistsProvider` — which answers for the
-**host** kernel, not the request's environment. Both misadvertisement directions
-were reachable, and are now pinned as regression tests:
-
-- the host kernel has `mcp` and the request's environment does not ⇒ `/discovery`
- advertised `routes.mcp` for an environment whose `/mcp` answers 501 — the
- `declared ≠ enforced` shape the probe was added to close;
-- the host kernel lacks it and the environment has it ⇒ the route was withheld
- from an environment that would have served it (`mcpServeable !== false` fails
- open only for a `null` probe, never for a confident `false` computed against
- the wrong kernel).
-
-The probe now calls `resolveRequestEnvironmentId` like its eight siblings. The
-`'platform'` guard and the `serviceExistsProvider` fallback are unchanged, and
-the unsubstituted `':environmentId'` route pattern is normalised to "no id"
-before the call — the entry point short-circuits on any truthy explicit value,
-so passing the pattern through would have sent it to `getOrCreate`. This also
-makes good the parity the probe's doc-comment already claimed with
-`resolveRegisteredServices`, whose kernel arrives as `ctx.__kernel` — set
-downstream of the same entry point.
diff --git a/.changeset/publish-door-advisories.md b/.changeset/publish-door-advisories.md
deleted file mode 100644
index 2b5d669160..0000000000
--- a/.changeset/publish-door-advisories.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-"@objectstack/spec": minor
-"@objectstack/metadata-protocol": minor
----
-
-The publish door now reports the runtime authoring gate's advisory findings (#9176). `POST /api/v1/meta/:type/:name/publish` carries the same optional, omitted-when-empty `advisories` key the save door already carries (#4463 D1/D3, #4717): `PublishMetaItemResponseSchema` declares it (`RuntimeAuthoringIssueSchema` elements, declared once in `@objectstack/spec`), and `publishMetaItem` attaches the findings the promotion-time gate run returns instead of discarding them. A clean publish's response bytes are unchanged — the key is present only when at least one `warning`/`info` finding was raised; `error` findings still refuse the promotion as the 422 envelope. This matters most for Studio / MCP / AI authors, whose designer takes draft-then-publish on every edit and has no CLI to surface the same findings.
diff --git a/.changeset/publish-meta-canonical-fold.md b/.changeset/publish-meta-canonical-fold.md
deleted file mode 100644
index ee89aec3f7..0000000000
--- a/.changeset/publish-meta-canonical-fold.md
+++ /dev/null
@@ -1,79 +0,0 @@
----
-"@objectstack/metadata-protocol": patch
----
-
-fix(metadata-protocol): route `publishMetaItem` through the `/meta` canonical-type fold (#8769)
-
-`canonicalizeMetaRequestType` is the `/meta` request boundary, and its own
-header describes it as the fold "all six entry points funnel through".
-`publishMetaItem` is a **seventh** entry point on the same URL family
-(`/api/v1/meta/:type/:name/publish` and the `…/published` overlay) and did not
-funnel through it: it reached the draftability check through
-`PLURAL_TO_SINGULAR`, the MANIFEST-COLLECTION map, which is the exact lookup
-#7894 replaced at the other six. One contract, two dialects, decided by which
-verb you used (Prime Directive #12).
-
-The fix is the same one line the other six carry, at the top of the method. What
-that line reaches — measured on `origin/main`, not inferred — differs by whether
-the type is in the manifest map, and the two halves are not the same severity:
-
-**The four manifest-absent types — fail-closed, but closed for the wrong reason
-and with the wrong verdict.** `field`, `seed`, `external_catalog` and
-`translation` are legitimately absent from `PLURAL_TO_SINGULAR` (they are not
-stack collections; that absence is precisely why #7894 moved the boundary onto
-the URL map). Unfolded, they arrived at the draftability check as unrecognised,
-where `isRuntimeCreateAllowed`'s "no static registry entry ⇒ this is a
-plugin-registered kind" arm answers **true** — the permissive plugin branch,
-taken for a type the platform itself declares. So a publish addressed
-`/meta/fields/showcase_task.title` PASSED a gate that `/meta/field/...` answers
-`403 NOT_OVERRIDABLE`, and only failed further down, on `404 no_draft`, having
-already forgotten which type it was judging. A publish addressed
-`/meta/translations/zh_cn` likewise never resolved the draft that
-`PUT /meta/translations/zh_cn` had folded and written under `translation`. After
-the fold: the first is refused `403 NOT_OVERRIDABLE` by its real registry entry,
-the second promotes the row it names.
-
-**Manifest-present types — one lookup that did NOT fail closed.**
-`promoteDraftForPublish` folds through the manifest map before the row lookup,
-so a publish addressed `/meta/views/case_grid` always resolved the canonical
-row. `getEffectiveLock` does not agree with it: its artifact limb folds, its
-**overlay limb queries `sys_metadata` with the raw `type`**. Addressed with the
-plural, the ADR-0010 `_lock` carried by the stored active row was looked up
-under a `type` no row has and came back `'none'` — which is not a neutral value,
-it is the verdict "the author declared no protection" (#5706) — while the
-promote one line later read the folded key and overwrote the row the lock
-protected. Measured on `origin/main`: `_lock: 'no-overlay'` plus a pending
-draft, canonical spelling `403 ITEM_LOCKED`, plural spelling **200 and the
-active body replaced**.
-
-That window is narrow and is stated at its real width rather than rounded up: it
-needs an environment kernel (the gate is skipped wholesale when `environmentId`
-is `undefined`), a lock carried by a *stored overlay* row rather than a packaged
-artifact, and a draft that predates the lock — because the save door refuses to
-mint one once the lock is live. It is nevertheless a lock gate that could be
-addressed around from the wire, and "a lock gate must not fail open" is the rule
-this file already carries.
-
-`promoteDraftForPublish`'s own `PLURAL_TO_SINGULAR` fold is **kept**, and the
-measurement is the reason: that helper's other caller is `publishPackageDrafts`,
-which feeds it stored row types. That is data at rest, where a legacy row
-written under a plural `type` is real and nothing rewrites it on upgrade — a
-different input class needing a different map, exactly as `canonicalMetaType`'s
-header describes. Deleting it as "now redundant" would have changed the batch
-path.
-
-`publishPackageDrafts` and `deletePackage` need no fold of their own: neither
-takes a caller-supplied `type` at all (both are addressed by `packageId`), and
-the per-row work they delegate is already covered — `deletePackage` routes every
-row through `deleteMetaItem`, which folds, and `publishPackageDrafts` reaches
-the manifest-map fold described above.
-
-The audit row and the publish receipt now record the canonical type too; both
-read `request.type`, so a publish addressed `/meta/views/case_grid` previously
-wrote `type='views'` into `sys_metadata_audit` for a row stored under `view`,
-and a compliance query on the canonical spelling did not find it.
-
-Pinned in `packages/objectql/src/protocol-publish-canonical-fold.test.ts`
-against a real engine and repository, with the reverse verification's direction
-predicted before it was run: predicted 3 red / 4 green, measured 3 red / 4
-green, each red for its predicted reason.
diff --git a/.changeset/publish-refuses-non-canonical-stored-type.md b/.changeset/publish-refuses-non-canonical-stored-type.md
deleted file mode 100644
index e82f00878c..0000000000
--- a/.changeset/publish-refuses-non-canonical-stored-type.md
+++ /dev/null
@@ -1,92 +0,0 @@
----
-"@objectstack/metadata-protocol": patch
-"@objectstack/metadata-core": patch
-"@objectstack/spec": patch
----
-
-fix(metadata): a package publish refuses a draft stored under a non-canonical metadata type, and the ADR-0010 audit writer asserts its `type` instead of folding it (#8908)
-
-
-
-**Two tightenings, one card, because they are the same defect at two layers.**
-
-`publishPackageDrafts` reads `sys_metadata` rows **at rest**, so #7894's `/meta`
-boundary fold never reached it. `promoteDraftForPublish` folds the stored
-spelling through `PLURAL_TO_SINGULAR` — the *manifest-collection* map, which
-legitimately omits types that are not stack collections. For those the fold is a
-**no-op**: the lookup key equals the stored spelling, the draft resolves, and the
-publish mints an ACTIVE row in the namespace `PUT /meta/field/…` answers
-403 NOT_OVERRIDABLE for. Measured on the card with the real repository over a
-stub engine:
-
-```
-publishPackageDrafts({ packageId: 'app.demo' })
- → { success: true, publishedCount: 1, published: [{ type: 'fields', name: 'legacy_field' }] }
-active row: { type: 'fields', name: 'legacy_field', package_id: 'app.demo' }
-audit row: { type: 'fields', name: 'legacy_field', outcome: 'allowed', code: 'ok' }
-```
-
-Every registry read and every compliance query on `field` misses an item the
-platform just reported as published — the #4432 shadowing shape, minted at
-publish time instead of at the URL, and the last route by which a pre-#7894 row
-could be re-promoted rather than migrated.
-
-**1. The publish refuses it, at the pre-flight, batch-atomically.** Same shape as
-the ADR-0028 namespace-prefix gate that already stands there: found before
-anything is promoted, failing the whole batch (`publishedCount: 0`,
-`published: []`) rather than publishing the healthy siblings around it, with one
-audit row per violation. The refusal names the row, names the canonical type, and
-states the re-author path; `failed[].code` is the new
-`STORED_TYPE_NOT_CANONICAL`, and the audit column's spelling is
-`stored_type_not_canonical`.
-
-The rule is **derived, not a list**: a spelling the platform's URL/registry map
-folds elsewhere *and* the manifest map leaves unchanged. Against the real maps
-that is **six** spellings — `fields`, `seeds`, `external_catalogs`,
-`externalCatalogs`, `translations`, `email_templates` — where the card named
-four; the last two would have been missing from any hand-written list, and a
-newly declared type that never reaches the manifest map is covered on the day it
-is declared. A manifest-**present** plural (`objects`) is deliberately *not* in
-the class: it is already fail-closed at the promote (`NO_DRAFT`, batch aborted)
-and keeps that verdict.
-
-⛔ Deliberately **not** included: migrating the row (a `_migrate-stored` /
-boot-reconciliation conversion). That was the other option on the card and is
-explicitly unruled — it stays available as a follow-up with its own appetite.
-
-**2. `recordMetadataAudit` refuses a non-canonical `type` (`AUDIT_TYPE_NOT_CANONICAL`)
-instead of folding it.** The writer used to open with
-`type: PLURAL_TO_SINGULAR[entry.type] ?? entry.type` — a lenient consumer, and a
-**tolerant-and-incomplete** one: the fold read the same manifest map, so the
-compliance trail came out canonical for the 29 types that never needed it and
-non-canonical for exactly the ones that did. Ruled the same direction as the
-refusal above: **fold at the boundary, assert at the writer.** Every call site
-that builds a row out of an at-rest `type` — all of them on
-`publishPackageDrafts` — now folds with `canonicalMetaType`; the `/meta` routes
-were already canonical by the time they got there. The throw sits **outside** the
-writer's best-effort `try`, because inside it the method's own `catch` would
-degrade the assert into a `console.warn`.
-
-The assert cannot refuse a canonical type (no canonical spelling folds
-elsewhere — 33 of 33, measured) nor a plugin-registered or otherwise
-unrecognised kind (`canonicalMetaType` is the identity for anything the static
-map does not carry), so it narrows the accept set without closing it.
-
-**Reachability was enumerated before the assert landed**, as the ruling required:
-`recordMetadataAudit` is private to `protocol.ts` with 11 call sites, `sys_metadata`
-rows have exactly one producer in the repository (`saveMetaItem` → `repo.put`,
-post-fold), and no current write path can mint a non-canonical stored type. The
-only non-canonical types that ever reached an audit write came from the batch
-publish's at-rest rows, which is what the boundary folds now cover.
-
-Also fixed, as a consequence of that fold rather than as a separate change: on
-the batch route `getEffectiveLock`'s overlay limb was queried with the raw stored
-spelling, so an ADR-0010 `_lock` carried by the canonical active row was looked
-up under a `type` no row has and came back `'none'` — the verdict "the author
-declared no protection". That is the batch twin of the hole #8769 closed on
-`publishMetaItem`.
diff --git a/.changeset/published-readme-docs-links-absolute.md b/.changeset/published-readme-docs-links-absolute.md
deleted file mode 100644
index e242a47cd5..0000000000
--- a/.changeset/published-readme-docs-links-absolute.md
+++ /dev/null
@@ -1,40 +0,0 @@
----
-"@objectstack/service-automation": patch
-"@objectstack/service-analytics": patch
-"@objectstack/service-knowledge": patch
-"@objectstack/knowledge-ragflow": patch
-"@objectstack/service-cache": patch
-"@objectstack/service-i18n": patch
-"@objectstack/service-job": patch
----
-
-Published READMEs link to the docs site in the one form that works on npm, on GitHub and on the docs site (#9632)
-
-**Seven docs links in these READMEs pointed nowhere.** They were spelled as a repo
-path rooted at `/` — `[Flows](/content/docs/automation/flows.mdx)` — and a README in a
-package's `files` array with `private` unset is rendered on the **npm package page** and
-on **GitHub**, not only in this repository. There a root-relative href resolves against
-`npmjs.com` and `github.com` respectively. It was not a docs-site route either:
-`apps/docs/lib/source.ts` mounts `loader({ baseUrl: '/docs' })` over `content/docs`, so
-the route for that first link is `/docs/automation/flows`, and `apps/docs/redirects.mjs`
-carries no `/content` source that would rescue the written form. Every target page
-existed and every one of them was reachable — only the links were not.
-
-All seven now use the absolute form the repo had already established in
-`create-objectstack`'s published READMEs: `https://docs.objectstack.ai/docs/...`, with
-the path taken under `content/docs` and the page extension dropped, because the route
-carries none. Each target was re-verified at the route level rather than as a file — the
-two that named a **directory** (`/content/docs/automation/`,
-`/content/docs/references/automation/`) resolve only because those directories carry an
-`index.mdx`; a directory without one is a 404, not a section.
-
-**Two more links in the same class were converted in the same pass.**
-`service-knowledge` and `knowledge-ragflow` pointed at
-`../../../content/docs/protocol/knowledge.mdx`. Those relative paths do resolve on both
-GitHub and npm, so they are a milder defect than the seven — but they land the reader on
-**raw MDX source** instead of the rendered page. They now point at the rendered page as
-well. `service-knowledge`'s link text changed with it: it was the source filename in a
-code span, which stops being an honest label once the destination is the page.
-
-No API, behaviour or type surface changes — this is the published documentation these
-packages ship.
diff --git a/.changeset/published-readme-symbol-claims-9544.md b/.changeset/published-readme-symbol-claims-9544.md
deleted file mode 100644
index c245f552c3..0000000000
--- a/.changeset/published-readme-symbol-claims-9544.md
+++ /dev/null
@@ -1,43 +0,0 @@
----
-"@objectstack/driver-sql": patch
-"@objectstack/mcp": patch
-"@objectstack/objectql": patch
-"@objectstack/spec": patch
----
-
-docs: four published READMEs stop documenting symbols and call sites that do not exist (#9544)
-
-All four packages ship `README.md` in their `files` array with `private` unset, so these
-are the pages npm renders. Each finding was re-measured against the **built `.d.ts`**, not
-against source, because that is what a consumer resolves through the `exports` map.
-
-- **`@objectstack/driver-sql`** — `import type { IDriver } from '@objectstack/spec'` named
- a type that exists **nowhere in the repository** (0 hits across every package's `src`
- and `dist`). The real contract is `IDataDriver` on `@objectstack/spec/contracts` — the
- one `SqlDriver` actually declares (`export class SqlDriver implements IDataDriver`). The
- adjacent operation list was corrected too: the method is `create`, not `insert`.
-
-- **`@objectstack/mcp`** — `DriverSql` has never existed (the export is `SqlDriver`), and
- the README then called `DriverSql.configure({...})` on it. Renaming alone would have
- been wrong twice over: `SqlDriver` has **no static `configure` either**, and `driver:`
- is not a key of `defineStack` at all. The example now declares a datasource the way the
- shipped templates do. `MCPServerPlugin.configure({...})` — five call sites — becomes
- `new MCPServerPlugin({...})`, the form the class's own JSDoc and every in-repo caller
- use. The documented options block claimed `serverName`, `autoRegisterTools`,
- `autoExposeObjects`, `enableStreaming`, `port` and `debug`; the real
- `MCPServerPluginOptions` is `name`, `version`, `transport`, `autoStart`, `instructions`,
- and the env switches are named instead.
-
-- **`@objectstack/objectql`** — `registerObject` is an **instance** method, so
- `SchemaRegistry.registerObject(...)` on the class could never run. The example now
- reaches it through the engine's registry and states the real parameter order
- (`schema, packageId, namespace?`).
-
-- **`@objectstack/spec`** — the protocol package's own front page imported
- `MCPServerConfigSchema` from `@objectstack/spec/ai`, which exports `MCPServerRefSchema`.
- A rename by itself would have swapped a broken import for a broken **parse**: the
- documented payload was built for a schema that does not exist, and
- `MCPServerRefSchema.safeParse` rejects it (`transport` is an enum of
- `stdio | http | websocket`, not an object, and `endpoint` is required and was absent).
- The example is now a payload that parses green, and the page says plainly that tools,
- resources and prompts are derived from metadata at runtime rather than authored there.
diff --git a/.changeset/qa-http-adapter-mount-discovery.md b/.changeset/qa-http-adapter-mount-discovery.md
deleted file mode 100644
index 91f05bb943..0000000000
--- a/.changeset/qa-http-adapter-mount-discovery.md
+++ /dev/null
@@ -1,38 +0,0 @@
----
-"@objectstack/core": patch
----
-
-fix(qa): `HttpTestAdapter` resolves the Data Protocol mount from the server's `/discovery`, and falls back to the convention loudly (#7983)
-
-The record-shaped `os test` action types (`create_record`, `read_record`,
-`update_record`, `delete_record`, `query_records`) built their URLs from the
-**defaults** of `RestApiConfigSchema.apiPath` and
-`CrudEndpointsConfigSchema.dataPrefix`, because the adapter is handed an origin
-and nothing else. A deployment that moved the mount got a 404 that reads like the
-suite author's own URL mistake rather than a platform limitation.
-
-The adapter now asks the server, following the `getRoute` precedent in
-`@objectstack/client`: **one memoised `GET {apiBase}/discovery` per run** (`os
-test` builds one adapter for the whole run), addressing whatever `routes.data`
-advertises, with the schema-derived convention as the fallback. Measured on a
-booted stack (REST route generator + dispatcher bridge), before and after:
-
-| deployment | before | after |
-|---|---|---|
-| stock | created | created |
-| `crud.dataPrefix: '/objects'` | `HTTP Error 404` | created |
-| `api.apiPath: '/api/2026-01'` | `HTTP Error 404` | `HTTP Error 404`, now naming the mount |
-
-The `apiPath` row is **not** closed, and the reason is structural: `apiPath`
-moves the base that `/discovery` is itself mounted under, so the document that
-would name the new mount sits behind the prefix that is missing. The one
-discovery document at a fixed path does not rescue it — `/.well-known/objectstack`
-advertises the **dispatcher's** `${prefix}/data`, measured as `/api/v1/data`
-under all three configs above — so it is deliberately not probed: trusting it
-would attach a false provenance ("discovery told us") to the same 404.
-
-Instead that case degrades loudly. Falling back to the convention prints a
-warning naming the mount it will address, the probe that failed and the remedy,
-and every 404/405 from a record action now carries the mount it addressed and
-where that mount came from. `api_call` is unchanged, issues no probe, and remains
-the escape hatch for a host the probe cannot reach.
diff --git a/.changeset/rare-donkeys-repeat.md b/.changeset/rare-donkeys-repeat.md
deleted file mode 100644
index d64ed16e1c..0000000000
--- a/.changeset/rare-donkeys-repeat.md
+++ /dev/null
@@ -1,23 +0,0 @@
----
-'@objectstack/service-datasource': patch
----
-
-Datasource-admin HTTP routes now require the `manage_platform_settings` capability, not merely authentication.
-
-All eleven routes under `/api/v1/datasources` — list, read, driver catalog, remote-table
-introspection, connection probes, credential migration, create, patch and remove — answer
-`403 PERMISSION_DENIED` to a caller that resolves to an identity holding no
-`manage_platform_settings` grant. The anonymous floor is unchanged (`401 UNAUTHENTICATED`).
-
-The capability is matched to what the adjacent Setup-admin families already gate on, not
-minted: `@objectstack/service-settings`'s platform-infrastructure namespaces (`mail`,
-`storage`, `sms`, `auth`, `ai`, `knowledge`) declare it for reads and writes alike, and this
-service's own Setup nav entry already declared `requiredPermissions:
-['manage_platform_settings']` for the console door in front of these routes. There is no
-read/write split for the same reason those namespaces have none: a datasource read returns
-stored connection configuration and live remote-schema introspection.
-
-Impact: `admin_full_access` carries `manage_platform_settings`, so platform admins are
-unaffected. A deployment that granted non-admin users access to Setup → Datasources through
-some other capability must now grant `manage_platform_settings` (or bind those users to a
-permission set carrying it).
diff --git a/.changeset/read-seam-empty-accumulator-discrimination.md b/.changeset/read-seam-empty-accumulator-discrimination.md
deleted file mode 100644
index d421a924d6..0000000000
--- a/.changeset/read-seam-empty-accumulator-discrimination.md
+++ /dev/null
@@ -1,62 +0,0 @@
----
-"@objectstack/metadata-protocol": patch
----
-
-fix(metadata-protocol): four read seams that FAILED no longer answer out of an empty accumulator — only an unprovisioned table is read as truthful emptiness (#8896)
-
-Four reads in `@objectstack/metadata-protocol` sat behind a bare `catch` that
-fell through — or, in one case, jumped — above a value the read was supposed to
-fill. Each handed its caller an answer indistinguishable from a legitimate one,
-with nothing logged and no field saying the answer was incomplete. Per ADR-0110
-D3 those are different facts, and at every one of these seams they have opposite
-consequences:
-
-- **`SeedLoaderService.loadExistingRecords()`** returned an empty `Map`. That map
- is not a cache — it IS the write decision, in all three of its callers, and
- "empty" means *write these rows*: the upsert pre-load turns every update into
- an INSERT, and `bulkWrite`'s `attempt > 1` recheck — the only thing standing
- between an at-least-once retry and a duplicate of every row the first attempt
- already committed (framework#3149) — is silently disarmed.
-- **`searchAll()`** skipped the object on a per-object `catch { continue; }`
- while the response still reported `totalObjects` / `totalHits` / `truncated`
- as though the sweep had been complete: a partial scan wearing a whole one's
- numbers.
-- **`findReferencesToMeta()`** dropped a whole source type on a per-matcher
- `catch { return; }`. That list answers "what would break if I delete this" and
- is rendered as the admin UI's "Used by" panel, so a silently short list reads
- as "nothing depends on it — safe to remove".
-- **`publishPackageDrafts()`** did not fall through: it pushed a **fabricated**
- ADR-0067 revert-plan entry, `{ existedBefore: false, prevVersion: null }` —
- the literal opposite of the healthy branch's `existedBefore: !!activeRow`.
- `existedBefore: false` means "revert = soft-remove", so reverting that commit
- DELETES an artifact whose previous version was supposed to be restored.
-
-None of the four `catch`es is removed; each is **discriminated by error type**,
-through the same shared `isMissingTableError` predicate
-(`@objectstack/metadata/errors`) that `DatabaseLoader`, `SysMetadataRepository`
-and `cascadeDeleteRelations` already use:
-
-- **benign, unchanged** — the table was never provisioned (schema sync not run
- yet). It can hold no rows, so the empty answer is the truth and each seam
- behaves exactly as before: the seed writes its rows, the search skips the
- object, the publish records `existedBefore: false`.
-- **everything else now surfaces** — a connection drop, a timeout, a permission
- denial, a query error, a missing column on a provisioned table. The caller
- receives the read's own failure, envelope intact.
-
-`findReferencesToMeta` is the one seam that gets no predicate of its own: it
-reads through `getMetaItems`, which already performs exactly this discrimination
-(`rethrowUnlessMetadataStoreUnprovisioned`, #5532) and raises a 503
-`SERVICE_UNAVAILABLE` for a real outage. The only thing its `catch` could
-swallow was that deliberate 503, so it is simply gone.
-
-No new error code and no new response field. The behavioural change is that a
-seed load, a global search, a reference scan or a package publish which used to
-report success over an unreadable store now reports the failure that made it
-unreadable. `publishPackageDrafts` refuses before Phase 1's transaction, so a
-refused publish leaves the draft pending and writes nothing.
-
-The comment above the publish capture claimed a capture failure "just omits that
-item from the revert plan". That was wrong twice — the code fabricated rather
-than omitted, and omitting would have left the item unreverted while reporting
-the turn undone — and it now describes what the code does.
diff --git a/.changeset/read-verb-canonical-meta-type-fold.md b/.changeset/read-verb-canonical-meta-type-fold.md
deleted file mode 100644
index 3495a58a67..0000000000
--- a/.changeset/read-verb-canonical-meta-type-fold.md
+++ /dev/null
@@ -1,56 +0,0 @@
----
-"@objectstack/metadata-protocol": patch
----
-
-fix(metadata): the three read-side `/meta` verbs reach the canonical type boundary — history, audit and references (#9157)
-
-
-
-Step ① of the maintainer ruling in #9180 (2026-08-16): **the `/meta` type
-segment is singular, always.**
-
-`auditMetaItem`, `historyMetaItem` and `findReferencesToMeta` each opened by
-deriving their type key from `PLURAL_TO_SINGULAR` — the MANIFEST-COLLECTION map
-that #7894 moved this boundary off — instead of calling
-`canonicalizeMetaRequestType`, which the nine sibling `/meta` verbs already
-call. That one call carries **both** the URL spelling map **and**
-`metaUrlSpellingRefusal`, and the refusal is the half these three could never
-reach: it lives *inside* the function they skipped.
-
-**What changes on the wire**, on `GET /api/v1/meta/:type/:name/history`,
-`…/audit` and `…/references`:
-
-| caller's `:type` | before | after |
-| --- | --- | --- |
-| `viewes` — an unrecognised spelling of a **declared** type | 200 with an empty body | **400 `INVALID_REQUEST`**, naming both accepted spellings (`view`, `views`) |
-| `translations`, `fields`, `seeds`, `external_catalogs` — recognised plurals of the four types absent from the manifest map | 200 with an empty body | 200 with the **real** rows |
-| `views` — a recognised plural already in the manifest map | unchanged | unchanged |
-| `fieldz` — reaches for no declared type | unchanged | unchanged; the refusal stays narrow, so a plugin-registered kind can never trip it |
-
-The harm being closed is the empty-accumulator shape: a plural read answered
-`{ "events": [] }` / `{ "references": [] }` — read by an operator as *"nothing
-depends on this"* — at exactly the moment they were about to rename or delete.
-**Loudly wrong beats quietly lying**, so a spelling the platform cannot honour
-is now refused with the canonical one named rather than answered emptily.
-
-Two measured details worth stating, because both invert an intuition:
-
-- On `historyMetaItem` the unfolded plural was not merely a wrong key, it was a
- door **around** a gate. `field` declares neither `allowOrgOverride` nor
- `allowRuntimeCreate`, so the canonical spelling is refused by the overlay gate
- and never reaches the store — while `fields` took
- `isRuntimeCreateAllowed`'s no-static-registry-entry arm (the plugin path,
- permissive by construction) and issued a real `sys_metadata_history` read
- keyed `'fields'`. Same empty body, opposite path.
-- On `findReferencesToMeta` the refusal is the **whole** visible change. Every
- `REFERENCE_PATHS` key is manifest-present and already folded, so a
- manifest-absent target still answers `{ "references": [] }` — which that
- method documents as a legitimate no-hit. Widening that registry is a coverage
- question, not a spelling one.
-
-Recognised plural spellings are **not** retired here — `metaUrlSpellingRefusal`
-returns `null` for `views` and for `translations`, and a pin asserts it. That is
-#9180 step ③, which the ruling requires to stay independently revertible.
diff --git a/.changeset/readonly-when-supplied-values.md b/.changeset/readonly-when-supplied-values.md
deleted file mode 100644
index 758c5f15df..0000000000
--- a/.changeset/readonly-when-supplied-values.md
+++ /dev/null
@@ -1,57 +0,0 @@
----
-"@objectstack/objectql": minor
----
-
-fix(objectql): a TRUE `readonlyWhen` no longer strips hook-derived values — the conditional strip judges only API-boundary callers (#9107)
-
-`stripReadonlyWhenFields` runs AFTER the before-phase hooks and was keyed on
-`name in data` over the POST-hook payload, so a value a `beforeUpdate` hook
-computed was judged exactly like a key the caller forged. Unlike the static
-`readonly` strip immediately beside it, it carried no `isSystem` exemption
-either — so a field locked by a TRUE predicate had **no server-side write path
-at all**: a hook derived it and the strip deleted it; a cron or plugin wrote it
-with `{ context: { isSystem: true } }` and the strip deleted that too.
-
-Net effect before this change: the derived-field pattern (hook-computed column)
-and a conditional form lock could not coexist on one field. An author wanting
-"visible on the form but locked" **and** "recomputed by a hook" had no
-spec-compliant spelling, and the failure was silent behind an HTTP 200.
-
-Measured downstream (steedos-labs/os-project-titanwind-ehr#1446):
-`equipment.next_maintenance_date` is hook-derived (last maintenance date + cycle
-days) and declared with an always-true `readonlyWhen` to render
-visible-but-locked. After a maintenance sign-off the recompute never landed, and
-a scheduler keyed on that date regenerated the same maintenance plan on every
-scan — a user-visible duplicate-plans loop, diagnosed only by reading the
-engine's strip order in the dist bundle.
-
-The conditional strip now carries the exact key discipline #5591 gave the static
-one, on **both** branches (by-id and multi-row) off one engine-entry snapshot: a
-key is judged only while it is still an own property of the caller's payload as
-it arrived at engine entry AND still holds that caller's value by `Object.is`. A
-key a hook added, or overwrote, is a server value and survives.
-
-**The API-boundary lock is unchanged, and a caller cannot launder a write
-through the hook phase.** To reach the exempt side of either test a value must
-differ from what arrived at engine entry — which only server code can arrange. A
-client that echoes the locked key back is stripped exactly as before; if a hook
-overwrites that key, what persists is the **hook's** value, never the client's.
-`isSystem` is still deliberately NOT an exemption for `readonlyWhen`: a state
-lock that any system-context write could bypass would not be a state lock (the
-frozen paid-invoice-lines case depends on it).
-
-What moves for callers:
-
-- A `beforeUpdate` hook may now write a field locked by a TRUE `readonlyWhen`.
- This is the sanctioned channel for a conditionally-locked derived field.
-- `onFieldsDropped` no longer reports such a key under `readonly_when` — it is
- written, not dropped, so reporting it would make the observability seam lie.
-- `strictReadonlyWrites` no longer refuses a write whose only `readonlyWhen`
- "drop" was a hook's own value; a caller-supplied locked field is still refused.
-- The `ERR_READONLY_FIELD_REJECTED` refusal message's `readonlyWhen` remedy
- clause now reads "every **API-boundary** caller, isSystem included" and names
- the hook path. The error `code` is unchanged; a pin on the exact message text
- moves with it.
-
-If an app relied on the strip discarding a hook's own write to a locked field,
-that write now lands — remove the hook assignment, or narrow the predicate.
diff --git a/.changeset/reaper-verifies-repoint-before-deleting.md b/.changeset/reaper-verifies-repoint-before-deleting.md
deleted file mode 100644
index 5efbd0869a..0000000000
--- a/.changeset/reaper-verifies-repoint-before-deleting.md
+++ /dev/null
@@ -1,45 +0,0 @@
----
-"@objectstack/service-settings": patch
----
-
-fix(settings): the rotated-secret reaper verifies the repoint instead of inferring it (#8262)
-
-`SettingsService.reapRotatedSecret` deleted the `sys_secret` row that
-`upsertRow` reported as `previousEnc`, and inferred that the repoint it was
-cleaning up after had taken effect from `previousEnc !== nextEnc`. That
-inference holds for the shipped adapter, which forwards
-`context: { isSystem: true }`. It does not hold for an adapter that drops
-`context` — the reader `SettingsEngine`'s own doc comment contemplates, and a
-documented extension point rather than a mistake nobody makes.
-
-With `context` dropped, `sys_setting.value_enc` is `readonly: true` so the
-UPDATE has it stripped, the row keeps naming the OLD handle, and the reaper
-then deleted **the ciphertext still in force**: `materialiseRow` dereferenced a
-dangling handle, got nothing, and the setting silently read as empty. That is
-unrecoverable — the audit trail records digests, never handles or ciphertext,
-so nothing can even name what was destroyed. Measured on the real engine over
-the real `SysSetting` / `SysSecret` schemas, three writes gave `sys_secret`
-`1 → 1 → 2` with `value_enc` pinned to a row that no longer existed.
-
-The reaper now re-reads the row after the write and deletes `previousEnc` only
-once storage confirms the row no longer names it. The criterion is
-`current !== previousEnc` rather than the narrower `current === nextEnc`:
-under a concurrent rotation the row may already have moved on to a third
-handle, where `previousEnc` is genuinely unreferenced and the narrower test
-would leak the orphan the reaping exists to prevent. Both refuse the case that
-matters.
-
-Every refusal branch (unreadable row, failed read, row still naming the
-handle) leaves an orphan and logs — the recoverable direction, and the one an
-orphan sweep can clean up; there is no recoverable direction on the other
-side. The added read sits behind every cheap guard, so it is paid only where a
-destructive delete would otherwise follow, and it is inside the same
-best-effort guarantee as the delete: a rotation is never failed by it.
-
-Latent rather than live: no shipped path reaches this, because the shipped
-adapter forwards `context`. The population at risk is third-party and custom
-`SettingsEngine` adapter authors — who also had no discovery path, since the
-warning on `SettingsEngine.update` still described only the pre-#8063
-consequence ("the rotated-away credential stays in force"). That warning now
-states the real consequence, and a non-forwarding adapter announces itself in
-the log instead of failing silently.
diff --git a/.changeset/record-change-reentrant-start-condition.md b/.changeset/record-change-reentrant-start-condition.md
deleted file mode 100644
index 563e7880fa..0000000000
--- a/.changeset/record-change-reentrant-start-condition.md
+++ /dev/null
@@ -1,63 +0,0 @@
----
-"@objectstack/service-automation": patch
----
-
-fix(automation): evaluate a record-change flow's start condition on the re-entrant dispatch its own write causes — the loop-breaker goes back to being a backstop (#8689)
-
-A `record-after-update` flow whose start condition, **as authored, is false on the
-flow's own write-back**, was still re-dispatched for the same record. Nothing ran
-away — the engine's last-resort re-entrancy breaker caught it every time — but the
-breaker was the *only* thing working, and its own WARN said so: *"Its start
-condition did not suppress the re-fire."*
-
-**Which of the two candidate mechanisms — measured, not assumed.** The report named
-two readings that need different repairs: the re-entrant dispatch *skips* condition
-evaluation, or evaluation *runs but aborts* and the abort is counted as a fire.
-Measured on a real booted kernel (ObjectQL + automation + record-change trigger on
-better-sqlite3), a flow guarded on `record.status != "escalated"` whose data node
-writes `status = "escalated"`:
-
-```
-dispatches for the record ........ 2 (the re-fire really happened)
-start-condition evaluations ...... 1 (the FIRST dispatch only)
-evaluations that threw ........... 0
-loop-breaker WARNs for that id ... 1
-```
-
-Two dispatches, one evaluation, zero throws: the first reading is the true one, and
-the second is falsified for this path. `AutomationEngine.execute()` checked the
-re-entrancy breaker **before** the start-condition gate and returned there, so on the
-one dispatch where an author's re-fire guard is load-bearing, the guard was never
-consulted at all.
-
-**The fix is the ordering, not a stronger breaker.** The gate now runs first; the
-breaker check moved below it. The re-entrant dispatch already carries the post-write
-row, so the condition evaluates `false` and the flow is suppressed with
-`condition_not_met` — by the guard its author wrote. Measured after the change on the
-same harness: 2 dispatches, **2** evaluations (the second returning `false` against
-`status = "escalated"`), **0** breaker WARNs, and the flow still fires and applies its
-write exactly as before.
-
-The breaker is **unchanged in strength**, deliberately — making it catch more while
-leaving evaluation broken would have been the wrong direction. A condition that is
-genuinely true on re-entry (the 2026-07-06 shape: a `boolean` persists as integer `1`
-on SQLite/libsql, and CEL `1 != true` is true, so `is_escalated != true` never trips)
-still lands on the breaker, at the same depth, with the same WARN and the same skip
-envelope. What changed is that reaching it now *means* something — the condition was
-evaluated and returned true — so the WARN states that as fact instead of inferring it.
-
-Two consequences worth naming for anyone reading logs or run history:
-
-- flows whose re-fire guard was already correct stop producing the breaker WARN
- entirely, and their re-entrant dispatch is now recorded as `condition_not_met`
- rather than `reentrancy_loop_guard`;
-- a run skipped by its condition, and a re-entrant dispatch refused by the breaker,
- no longer release the re-entrancy key — only the run that took it does. Releasing a
- key it never owned would have disarmed the breaker for the run still on the stack,
- which is exactly the runaway the breaker exists to stop.
-
-The regression pins assert the reporter's own three-legged probe design together —
-the flow actually fired, no breaker WARN carries that record's id, and the start
-condition was **evaluated** at the re-fire against the post-write row and returned a
-verdict rather than throwing. Asserting only "the flow terminated" would be vacuous
-here: the breaker already made that true.
diff --git a/.changeset/record-chatter-position-renderer-vocabulary.md b/.changeset/record-chatter-position-renderer-vocabulary.md
deleted file mode 100644
index 1eb00e2043..0000000000
--- a/.changeset/record-chatter-position-renderer-vocabulary.md
+++ /dev/null
@@ -1,45 +0,0 @@
----
-"@objectstack/spec": minor
----
-
-fix(spec): `record:chatter` / `record:discussion` `position` speaks the renderer's vocabulary, and the row's schema defaults are dropped (#8762)
-
-**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
-launch-window convention ships it as `minor`; the migration prescription is
-registered under protocol major 18, where `os migrate meta` users will look).
-
-`RecordChatterProps.position` declared `sidebar | inline | drawer` — a
-vocabulary NO renderer read point ever compared. Measured at objectui pin
-`665661ab0932`, the renderer chain is self-consistent in three places and
-speaks `bottom | right | left`: `RecordChatterPanel` docks on `right`/`left`
-and renders in flow on `bottom`, the designer registration publishes
-`enum: ['bottom', 'right', 'left']`, and the renderer merge falls back to
-`bottom`. So the schema's own default (`sidebar`, materialized onto every
-parsed node that said nothing) was a silent no-op falling through to the
-in-flow render, while the value that actually docks the panel (`right`) was
-refused at publish. The maintainer ruling (2026-08-15) converged the row on
-the renderer's vocabulary — one vocabulary, no mapping layer.
-
-**FROM → TO:** `position: 'sidebar'` → `'right'` (the docked side panel the
-spelling meant); `'inline'` → `'bottom'` (the in-flow branch it already
-landed in); `'drawer'` → `'right'` (no overlay drawer was ever implemented —
-the docked panel is the nearest surviving intent). One-line fix: re-spell
-`position` to `bottom`/`right`/`left`; `os migrate meta` rewrites sources
-mechanically via the ADR-0087 conversion
-`record-chatter-position-vocabulary`, and stored `sys_metadata` rows replay
-clean through the rehydration seam. A live author gets a per-value "was
-removed" prescription from the enum's own error map.
-
-**All three schema defaults are dropped** (`position: 'sidebar'`,
-`collapsible: true`, `defaultCollapsed: false`) per the `maxVisible`
-principle — renderer fallbacks stay the renderer's facts. The old
-`collapsible` default *inverted* the renderer merge's own `false` fallback,
-turning "the author said nothing" into "the author asked for collapsible". A
-page that wants the collapse affordance authors `collapsible: true`
-explicitly; unset keys now parse to nothing and the renderer decides.
-
-The row stays ONE shared schema object for `record:chatter` AND
-`record:discussion` (the #8744 pairing) — both names accept and refuse
-identically. The objectui renderer is unchanged.
-
-
diff --git a/.changeset/record-package-commit-durability-9066.md b/.changeset/record-package-commit-durability-9066.md
deleted file mode 100644
index c27ce9c15d..0000000000
--- a/.changeset/record-package-commit-durability-9066.md
+++ /dev/null
@@ -1,40 +0,0 @@
----
-"@objectstack/metadata-protocol": patch
----
-
-fix(metadata-protocol): a failed `sys_metadata_commit` write is reported instead of swallowed — the turn that cannot be reverted is now visible to an operator (#9066)
-
-`recordPackageCommit` — the ADR-0067 commit writer `publishPackageDrafts` calls
-with the revert plan it just captured — sat behind a bare `catch` that answered
-`null` for every reason, with nothing logged. The comment's premise was true
-(the publish already succeeded and cannot be unwound) but its conclusion —
-"grouping is a best-effort overlay" — understated the row: `sys_metadata_commit`
-is the ONLY record of a turn's revert plan (`existedBefore` / `prevVersion` per
-artifact), the thing `revertCommit` and `rollbackToPackageCommit` act on. When
-the insert failed, the artifacts went live, the response read `success: true`
-with `commitId` merely absent, the turn could never be reverted, and no line
-anywhere said so — so a commit store that was failing kept failing, losing every
-later publish's plan the same silent way.
-
-The failure is now discriminated by error TYPE, through the shared
-`isMissingTableError` predicate the read seams in this file already ask:
-
-- an **unprovisioned** `sys_metadata_commit` (a first boot, or an environment
- kernel composed without the commit log) is a configuration fact, identical on
- every publish and fixed in one place — reported at `info`, once per protocol
- instance, naming the consequence and how to provision the store;
-- **every other** failure (connection drop, timeout, permission denial, schema
- drift on that table) is a durability degradation and is reported at `error`,
- once per turn, naming the package, the operation, the item count, the driver's
- own reason, that the publish itself succeeded and still reports success, and
- the fix.
-
-Publish semantics are unchanged: the `catch` still returns `null`, the publish
-still succeeds, and no response field was added — whether the caller should be
-told the turn is unrevertible is a separate, undecided question.
-
-The gate that stops this from regressing is extended in the same change: the
-insert now goes through a named `persistPackageCommitRow`, declared in
-`DURABILITY_CRITICAL_CALLEES` in
-`scripts/check-durability-degradation-log-level.mjs`, so a future edit that
-quiets this `catch` fails CI instead of shipping.
diff --git a/.changeset/record-views-drop-ip-address-column.md b/.changeset/record-views-drop-ip-address-column.md
deleted file mode 100644
index e2106c0b57..0000000000
--- a/.changeset/record-views-drop-ip-address-column.md
+++ /dev/null
@@ -1,22 +0,0 @@
----
-"@objectstack/plugin-audit": patch
----
-
-fix(audit): `record_views` list view drops its always-empty `ip_address` column, replaced with `actor` (#9539)
-
-`sys_audit_log`'s `record_views` list view (the "who viewed this record" screen, #8992)
-declared an `ip_address` column, but `buildRow` in `read-audit.ts` never stamps that key
-on a `read` row — client-fingerprint fields are populated on auth events only. The column
-was structurally empty on every row this view can ever show, which on a compliance
-surface reads as "we captured the fingerprint and this request had none" rather than
-"not captured" — the same 审计面宁窄勿谎 (narrow-not-untruthful) defect class #7675 /
-#8147 / #8315 retired from this object's `action` enum, one layer down on a column.
-
-Replaced with `actor`, which the read writer DOES stamp on every row and which attributes
-a service principal (`svc:`) that `user_id` structurally cannot hold. Pinned by
-`sys-audit-log-record-views-columns.test.ts`, which derives the read writer's actually-
-stamped key set from a real engine run rather than a hand-copied list, so the class can't
-regrow silently.
-
-Maintainer ruling 2026-08-18 + triage auto-adjudication 2026-08-19 (both Option 1).
-Stamping viewer IP (Option 2) was explicitly NOT commissioned in this change.
diff --git a/.changeset/redactor-head-invariant-guard.md b/.changeset/redactor-head-invariant-guard.md
deleted file mode 100644
index e95bdf09cb..0000000000
--- a/.changeset/redactor-head-invariant-guard.md
+++ /dev/null
@@ -1,53 +0,0 @@
----
-"@objectstack/objectql": patch
----
-
-fix(objectql): the redactor's end-of-message head invariant becomes a load-time guard and a type, not a doc comment (#9359)
-
-#9275 made the driver-fault statement cut **template-aware**: a separator standing
-immediately before a *measured* diagnostic head is the true cut point wherever it falls,
-so the head survives and the caller's value is dropped whole. That amendment is safe
-because of exactly one property:
-
-> **Only an end-of-message template may declare a head.**
-
-That property is what bounds a hostile value's influence to **over-redaction** — a value
-spelling a known head can suppress a real diagnostic and show a forged one, but it cannot
-make a value leak. Give a `head` to a family with a **right anchor** and the same cut keeps
-everything after that anchor, which on such a cut is statement, which is caller values.
-
-Until now the invariant was held by **prose plus one behavioural case** that forges the
-heads the table declares *today*. Nothing stopped a future author adding a head-bearing
-row whose `whole` is not end-anchored — the single shape that turns the amendment into a
-leak surface.
-
-**The argument for closing it structurally comes from this file's own history.** The head
-note once claimed leak-freedom rested on taking the LAST matching head as well as on
-end-of-message. Ablated: with the cut changed to take the FIRST head, all 50 cases in the
-suite stayed green — an end-of-message pattern matches only once, from its earliest
-position. A documented property about this very mechanism was wrong for weeks of reading
-and fell only to an ablation. A doc comment is not a guard.
-
-The invariant is now held in two places a future author cannot write past:
-
-- **The type.** `ValueBearingTemplate` is a union of `AnchoredTemplate` (`tail?`, and
- `head?: never`) and `EndOfMessageTemplate` (`head`, and `tail?: never`), so a row
- carrying both no longer compiles.
-- **`assertHeadBearingTemplatesAreEndAnchored()`**, called at module load over
- `VALUE_BEARING_TEMPLATES` — the `assertMetaUrlSpellingsAgree()` shape. For every row
- that declares a `head` it requires the `whole` to end with `$()`, to carry no `m` flag
- (under `m`, `$` is end of LINE, so an "end-anchored" template would stop at the first
- newline of a multi-line dump and leave the rest standing) and to have exactly two
- capture groups (`redactDiagnosticValues` reads `whole[1]` and `whole[2]` by index).
-
-**No redaction behaviour changes.** The statement cut, the head set and the templates
-themselves are untouched; the guard only refuses tables that could not have been correct.
-The existing behavioural case that forges each head is kept — it is evidence, and the
-guard is additional rather than a replacement.
-
-The guard is proved to FIRE rather than merely to exist: eight new cases drive each shape
-it rejects (right-anchored `whole` with a head, the `m` flag, a wrong group count, a bad
-row behind a good one) and pin that it does **not** fire on the shipped rows or on
-right-anchored rows that correctly take a `tail`. Reverse-verified both legs — a
-head-bearing right-anchored row added to the shipped table makes every test in the package
-fail at module load, and a row carrying `head` and `tail` together fails `tsc`.
diff --git a/.changeset/reference-paths-derivation.md b/.changeset/reference-paths-derivation.md
deleted file mode 100644
index c22a0562a2..0000000000
--- a/.changeset/reference-paths-derivation.md
+++ /dev/null
@@ -1,39 +0,0 @@
----
-'@objectstack/metadata-protocol': minor
----
-
-Derive the metadata reference graph from the type schemas instead of curating it by hand
-
-`GET /api/v1/meta/:type/:name/references` — the admin "Used by" panel, rendered
-immediately before a rename or a delete — was driven by a hand-written table of
-seven target types and forty dotted paths. Measured against the schemas it was
-supposed to describe, **34 of those 40 paths named properties no metadata type
-declares**: `app.navItems[]` / `app.tabs[]` (the schema declares `navigation`
-and `areas`), `agent.tools[]` (removed in `@objectstack/spec` 17),
-`permission.objects[].name` (a name-keyed record, not an array),
-`object.fields{}.referenceTo` (the field property is `reference`),
-`dashboard.widgets[].view`, `page.viewName`, and every path the table listed for
-`flow`. Five of its seven target types therefore answered `{ references: [] }`
-unconditionally, on every deployment, while appearing to be covered — and an
-empty panel reads as "nothing depends on this, safe to delete".
-
-Coverage is now derived at boot from `DEFAULT_METADATA_TYPE_REGISTRY` and each
-type's Zod schema, so a newly declared metadata type arrives covered instead of
-waiting for someone to remember it. Seventeen target types now resolve real
-reference sites, including `permission`-to-object grants (through the record
-key, which the old path grammar could not express), `translation`, `dataset`,
-`action`, `report`, `doc` and `datasource`, plus flow-node references such as
-`subflow`. References nested inside recursive containers — a view named from a
-third-level app navigation group — are found at any depth, which no finite path
-list could do.
-
-No wire change: the response shape, status codes and error envelope are
-untouched. The `path` and `kind` values now describe where the reference was
-actually found rather than which table row matched.
-
-Two gaps are deliberately declared rather than papered over: `external_catalog`
-resolves no schema, so its references are not computable and it is named in the
-derivation's `unwalkableSourceTypes` (pinned by a test, so the set cannot grow
-silently), and reference properties whose name does not spell their target —
-`FieldSchema.reference` is the one carried — need a producer-side annotation to
-become derivable.
diff --git a/.changeset/reference-tables-default-bearing-optional.md b/.changeset/reference-tables-default-bearing-optional.md
deleted file mode 100644
index 8bef5e6229..0000000000
--- a/.changeset/reference-tables-default-bearing-optional.md
+++ /dev/null
@@ -1,44 +0,0 @@
----
-"@objectstack/spec": patch
----
-
-fix(spec): reference tables stop marking `.default()`-bearing members as required, and name the default instead (#8703)
-
-The Required column of every `content/docs/references/**` property table mirrored
-the emitted JSON Schema's `required` array. `build-schemas.ts` emits the
-**output** (post-parse) shape for 1458 of the 1582 published documents, falling
-back to the **input** shape only when output emission throws — and in an output
-shape a `.default()`-bearing member is listed in `required`, because the parse
-always produces it. So the column answered "must I write this?" with `✅` for
-keys the author may freely omit.
-
-**Measured on the emitted tree: 2526 property occurrences across 529 documents**
-were in `required` while carrying a `default`. `kernel/metadata-plugin.mdx` is
-the specimen the card was filed on — `enableEvents`, `validateOnWrite`,
-`enableVersioning`, `cacheMaxItems` and `bootstrap` all read `✅`, and all five
-are omittable.
-
-Two consequences, both fixed here:
-
-- Reference tables are read far more often by an AI author than by a human
- (ADR-0033), and omitting optional keys is that author's normal mode. A wall of
- `✅` teaches over-specification, and buries the genuinely-required keys among
- the ones that are not.
-- The same member rendered `✅` on an output-shape page and `optional` on one of
- the 124 input-shape pages, so a refactor that merely flipped a def between the
- two emission modes rewrote its whole Required column with no semantic change to
- what an author writes.
-
-**The fix reads `default` rather than `required`**: a property carrying a
-`default` is author-omittable by construction in *both* emission modes, so it now
-renders `optional (default: \`false\`)` — strictly more information than either
-previous cell, since the value an author gets by omitting the key was nowhere on
-the page before. A structural default too wide for the cell renders
-`optional (has default)` (13 cells; the budget's discontinuity is documented at
-`INLINE_DEFAULT_WIDTH_LIMIT`), and a property with no default is untouched in
-both directions.
-
-**The JSON Schemas are deliberately unchanged.** `build-schemas.ts` is not
-touched by this fix: the emitted artifacts keep describing the post-parse shape
-and keep validating post-parse data. Only the doc renderer reads the author's
-question differently. 146 reference pages are regenerated.
diff --git a/.changeset/references-route-capability-gap-refused.md b/.changeset/references-route-capability-gap-refused.md
deleted file mode 100644
index e5f2c3572a..0000000000
--- a/.changeset/references-route-capability-gap-refused.md
+++ /dev/null
@@ -1,52 +0,0 @@
----
-"@objectstack/rest": patch
----
-
-fix(rest): a missing `findReferencesToMeta` capability is refused, not answered as "nothing depends on this item" (#9326)
-
-`GET /api/v1/meta/:type/:name/references` feature-detects `findReferencesToMeta`
-on the resolved protocol. When the method was absent the route answered
-`200 { references: [] }` — so a **capability gap** reached the wire as the
-statement **"nothing depends on this item"**.
-
-Per ADR-0110 D3 those are different facts, and here they have opposite
-consequences. The consumer is the admin "Used by" panel, whose empty state reads,
-verbatim from `objectui`'s `metadata-admin/i18n.ts`:
-
-```
-'engine.edit.refsEmptyDesc': 'Nothing in the metadata graph points at this item. Safe to delete.'
-```
-
-An operator about to delete something was shown that sentence on a deployment
-where the question had never actually been asked.
-
-The branch now refuses:
-
-```
-501 { error: { code: 'NOT_IMPLEMENTED',
- message: 'protocol.findReferencesToMeta() 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.
-
-**Does any caller's observed response change? Yes, on one deployment shape, and
-only there.** A protocol that *has* the method is untouched: both an empty and a
-non-empty result 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 `findReferencesToMeta` 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/register-dispatcher-gate-error-codes.md b/.changeset/register-dispatcher-gate-error-codes.md
deleted file mode 100644
index 6185f64457..0000000000
--- a/.changeset/register-dispatcher-gate-error-codes.md
+++ /dev/null
@@ -1,25 +0,0 @@
----
-"@objectstack/spec": patch
----
-
-Register, in `ERROR_CODE_LEDGER`, the seven error codes the dispatcher
-error-vocabulary gate (#8087, maintainer ruling 2026-08-12: option B delivered
-as a gate) reported as reaching a wire `error.code` with no ledger row — so the
-bodies that carry them parse against `ApiErrorSchema` instead of failing the
-schema they claim to satisfy:
-
-- `FLOW_FAILED` (`@objectstack/runtime`) — a flow that ran and rejected (#3962)
-- `QUERY_OBJECT_MISMATCH` (`@objectstack/metadata-protocol`) — query body's
- `object` key names a different object than the route
-- `ERR_AUTONUMBER_COLLISION`, `ERR_TRANSACTION_UNSUPPORTED`,
- `ERR_CROSS_DATASOURCE_TRANSACTION_WRITE`, `ERR_HOOK_TARGET_REBIND`
- (`@objectstack/objectql`) — the unswept members of the package's `ERR_*`
- family
-- `FIELD_VISIBILITY_UNRESOLVED` (`@objectstack/rest`) — ADR-0106 D6 tier 3
- fail-closed 503
-
-Owning packages follow #7504 provenance (the package whose source stamps the
-code). No wire value changes: every code was already emitted; the ledger now
-admits what is measured on the wire. `STORAGE_FAILURE` (producer-less) and
-`DUPLICATE` (the pinned witness of the sandbox-authored limb, #9106) are
-deliberately not registered.
diff --git a/.changeset/register-flow-conversion-conflict.md b/.changeset/register-flow-conversion-conflict.md
deleted file mode 100644
index a15c10d392..0000000000
--- a/.changeset/register-flow-conversion-conflict.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-"@objectstack/spec": patch
----
-
-Register `FLOW_CONVERSION_CONFLICT` (409) in the ADR-0112 error-code ledger under
-`@objectstack/metadata-protocol` (#9567). The code was already live on the wire —
-`saveMetaItem`'s flow-conversion rename guard (`protocol.ts`) has thrown it since
-ADR-0078 landed, already SCREAMING_SNAKE — but was invisible to
-`check:dispatcher-error-vocabulary`'s scan because the site stamps it through a
-cast (`(err as any).code = 'FLOW_CONVERSION_CONFLICT'`) rather than the bare-
-identifier `assign` shape the scan matched at the time. This is an ordinary,
-additive admission: no accept/reject behavior, no producer, and no wire shape
-changes.
diff --git a/.changeset/register-unique-scope-confirmation-required.md b/.changeset/register-unique-scope-confirmation-required.md
deleted file mode 100644
index bc40dd3910..0000000000
--- a/.changeset/register-unique-scope-confirmation-required.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@objectstack/spec': patch
----
-
-Register `UNIQUE_SCOPE_CONFIRMATION_REQUIRED` (`@objectstack/cloud-connection`) in `ERROR_CODE_LEDGER` — the ADR-0120 D5e posture-gate refusal the marketplace install seam answers (409) when an app declares installation-wide unique constraints under the `isolated` tenancy posture. The code was already on the wire with a live reader (`os package install` branches on it to print the per-index decision list) but sat outside the closed ADR-0112 vocabulary (`StandardErrorCode ∪ ERROR_CODE_LEDGER`), invisible until #9223 taught the dispatcher-vocabulary gate to see a constant stamped in an object literal. No wire behavior changes — the value was already emitted; `ApiErrorSchema` now accepts what the wire actually carries. The now-discharged `pending-registration` row ratchets out of `packages/runtime`'s dispatcher-error-vocabulary table in the same change.
diff --git a/.changeset/replay-jsdoc-run-history.md b/.changeset/replay-jsdoc-run-history.md
deleted file mode 100644
index 3bd5719f22..0000000000
--- a/.changeset/replay-jsdoc-run-history.md
+++ /dev/null
@@ -1,19 +0,0 @@
----
-"@objectstack/spec": patch
----
-
-fix(spec): `IJobService` JSDoc stops calling `sys_job_run` "the audit trail" — it's job run history (#9673)
-
-`packages/spec/src/contracts/job-service.ts` called the storage `replay()` and
-`JobRunOutcome.reason` write to "the execution audit trail" / "the audit
-trail" in three spots. The binding #9633 ruling: `sys_job_run` is **job run
-history**, not the audit trail — `sys_audit_log` is the audit surface, with
-its own opt-in, writer and retention. Published `.d.ts` tooltip text pointing
-readers at the wrong subsystem was exactly the conflation that ruling
-rejected.
-
-Wording only — `reason?`, `replay?()` and their runtime behavior are
-unchanged. `replay`'s JSDoc also gains the caveat #9673 suggested: recording
-anything durable depends on an adapter that persists run history at all
-(e.g. `DbJobAdapter`'s `recordRuns` option), since #9633 made that
-conditional where the prose previously read as unconditional.
diff --git a/.changeset/required-multi-value-empty-array-rejected.md b/.changeset/required-multi-value-empty-array-rejected.md
deleted file mode 100644
index 43ac95d90a..0000000000
--- a/.changeset/required-multi-value-empty-array-rejected.md
+++ /dev/null
@@ -1,31 +0,0 @@
----
-"@objectstack/objectql": patch
----
-
-fix(objectql): `[]` no longer satisfies `required` on a multi-value field — the #9447 ruling's enforcement half (#9476)
-
-
-
-Per the #9447 maintainer ruling (2026-08-18): `required` on a multi-value
-field means **non-empty array**. The empty set is representable — it reads
-back as `[]`, never `null` — so `required` judges emptiness.
-
-Before this, `validateRecord` judged `required` through `isMissing`, which
-knows `undefined` / `null` / blank strings — an explicit `[]` sailed through
-on both INSERT and UPDATE while `null` was correctly rejected. Now:
-
-- INSERT: `[]` on a required multi-value field is rejected — 400
- `VALIDATION_FAILED`, field code `required`, the same envelope a missing
- value already got.
-- UPDATE: a SUPPLIED `[]` is an explicit clear — rejected with the distinct
- `required_cleared` sentence (wire code `required`), exactly like an
- explicit `null`. An omitted field still never 400s — legacy rows rest.
-- Scope is the spec's own multi-value predicate (ADR-0104 D1):
- inherently-multi option types plus multi-capable types flagged
- `multiple: true`. Structured-JSON fields are untouched — `[]` there is a
- document, not an emptied set. Populated arrays and non-required
- multi-value fields are untouched.
diff --git a/.changeset/rest-approvals-wire-codes-ledger.md b/.changeset/rest-approvals-wire-codes-ledger.md
deleted file mode 100644
index f6c832db62..0000000000
--- a/.changeset/rest-approvals-wire-codes-ledger.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@objectstack/spec': patch
----
-
-Register the nine `@objectstack/rest` wire codes the #8885 population sweep measured outside the closed ADR-0112 vocabulary (`StandardErrorCode ∪ ERROR_CODE_LEDGER`): `THROTTLED` (429, the approvals remind cool-down rejection the spec contract documents) and the eight template-generated `APPROVAL__FAILED` terminal 500 codes (`APPROVE`, `REJECT`, `REVISE`, `RESUBMIT`, `REASSIGN`, `REMIND`, `REQUEST_INFO`, `COMMENT`) whose literal-spelled siblings were already registered. No wire behavior changes — these values were already emitted; `ApiErrorSchema` now accepts what the wire actually carries.
diff --git a/.changeset/rest-meta-write-org-scope.md b/.changeset/rest-meta-write-org-scope.md
deleted file mode 100644
index d529322ca9..0000000000
--- a/.changeset/rest-meta-write-org-scope.md
+++ /dev/null
@@ -1,36 +0,0 @@
----
-'@objectstack/metadata-core': patch
-'@objectstack/runtime': patch
-'@objectstack/rest': patch
----
-
-REST `/meta` write doors now carry the caller's organization, so audit rows are no longer stamped environment-wide
-
-`PUT /meta/:type/:name` (both arities), `DELETE /meta/:type/:name`,
-`POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` passed no
-organization, so every `sys_metadata_audit` row a REST-authored metadata write produced was
-stamped `organization_id: null`. Composed with the scoped audit read shipped alongside it —
-which returns own-org rows **plus** environment-wide ones, a limb that is required rather
-than optional — that left every REST-authored audit row readable by every tenant, carrying
-its `actor`, `note`, `lock_state` and `request_id`. The read side could not close this: the
-rows were genuinely unscoped, so no filter could separate them.
-
-The organization is taken from the execution context these doors already resolve, and is
-threaded through `organizationIdForMetaWrite` — the same registry-derived predicate the
-runtime `/metadata` dispatcher uses. Types the registry declares `allowOrgOverride: true`
-(`view`, `dashboard`, `report`, `translation`, `email_template`) now scope both the overlay
-row and its audit row to the caller's organization; every other type continues to write
-environment-wide, because its write genuinely is environment-wide and the protocol refuses
-an org-scoped write for it. `null` is now reserved for writes that really are
-environment-wide.
-
-Two behaviour changes ride along, both required for the fix to be usable rather than
-separate improvements: `publish` and `rollback` resolve their row through the organization,
-so scoping the save without scoping them would have broken the draft → publish loop; and
-`GET /meta/:type/:name/published` is now organization-scoped (organization-first, then
-environment-wide), without which it would answer 404 for an item the same caller had just
-published through the same transport.
-
-`organizationIdForMetaWrite` / `declaresOrgOverride` moved from `@objectstack/runtime` into
-`@objectstack/metadata-core` so both doors share one implementation — `@objectstack/rest`
-cannot import from `runtime`, which depends on it. Runtime behaviour is unchanged.
diff --git a/.changeset/resync-skip-summary-explanation.md b/.changeset/resync-skip-summary-explanation.md
deleted file mode 100644
index 09f094fab1..0000000000
--- a/.changeset/resync-skip-summary-explanation.md
+++ /dev/null
@@ -1,37 +0,0 @@
----
-'@objectstack/cli': patch
----
-
-`os meta resync`: explain a nonzero skip count instead of leaving it to look like a no-op (#9184)
-
-`resynced 0 / skipped 8` is a **permanent, by-design** outcome on any install
-created before #8692's forward-only ruling — the platform's own seeder wrote
-the `'admin'` stamp those rows still carry, and #8692 deliberately never
-migrates it (a stored `'admin'` cannot be told apart from a genuine Setup
-takeover). The docblock half of this (#9130 / PR #9183) already explained it
-in source; this is the half the operator actually sees, at the terminal,
-without going looking:
-
-```
-⚠ Left 8 set(s) untouched (admin- or package-owned).
- Expected, not a failure — resync only reconciles platform-owned rows. A stored
- 'admin' stamp (or the legacy 'user' spelling) isn't always a deliberate Setup
- takeover: on installs from before #8692, the platform's own seeded defaults
- carry that same stamp, so a persistent skip count here can be permanent by
- design. A package-owned row, by contrast, is always a deliberate override by
- the package that owns it.
-```
-
-The new line fires on the same condition the skip-count summary itself
-already used (`resyncSkipped > 0`) — a partial skip gets the same
-explanation as a total one. `--json` output is unchanged; `resyncSkipped` is
-already a plain number a script can act on without prose.
-
-While here, the skip-count summary's own wording is corrected: it read
-`(admin- or package-owned override)`, uniformly claiming "override" for
-both provenance classes. That is accurate for a package-owned row (always a
-deliberate override, per the seeder's docblock) but was already the exact
-false framing PR #9183 removed from the per-row log line for the
-admin-owned case — a pre-#8692 seeded row was never overridden by anybody.
-The summary now reads `(admin- or package-owned)`, and the new explanatory
-line carries the nuance instead.
diff --git a/.changeset/retire-batch-error-codes.md b/.changeset/retire-batch-error-codes.md
deleted file mode 100644
index 7e11812086..0000000000
--- a/.changeset/retire-batch-error-codes.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-'@objectstack/spec': minor
----
-
-Retire `BATCH_PARTIAL_FAILURE`, `BATCH_COMPLETE_FAILURE` and `TRANSACTION_FAILED` from `StandardErrorCode` (ADR-0112 amendment 2026-08-18, ADR-0049 enforce-or-remove, #9266). Breaking for the error vocabulary: the three spellings now fail `StandardErrorCode` / `ApiErrorSchema` parse. No producer has ever emitted any of them — the batch surface reports these conditions per row instead, with strictly more information.
-
-FROM → TO: `error.code === 'BATCH_PARTIAL_FAILURE' | 'BATCH_COMPLETE_FAILURE' | 'TRANSACTION_FAILED'` (envelope-level, never emitted) → read the per-row `results[].errors[].code` — a rolled-back atomic batch marks each row `ROLLED_BACK`, rows the abort never reached `NOT_ATTEMPTED`, and the causal row keeps its own error (HTTP 200, both codes ledger-registered). One-line fix: delete any branch on the three retired spellings (it never fired) and branch on the per-row codes instead.
-
-
diff --git a/.changeset/retire-remote-template-catalog.md b/.changeset/retire-remote-template-catalog.md
deleted file mode 100644
index 3037c88739..0000000000
--- a/.changeset/retire-remote-template-catalog.md
+++ /dev/null
@@ -1,23 +0,0 @@
----
-'create-objectstack': minor
----
-
-Retire the five remote content templates from the scaffolder's catalog.
-
-`todo`, `compliance`, `content`, `contracts` and `procurement` were delisted
-from the official ObjectStack template marketplace and are no longer
-maintained, but the CLI carried its own hardcoded catalog and never learned
-that: `--help` recommended all five by name with marketing descriptions, and
-the `Available:` line on a bad `-t` offered them too.
-
-- `blank` (bundled, offline) is now the whole catalog, so the help text
- advertises only what is actually supported.
-- Asking for one of the five by name — `-t todo` in an old script or tutorial —
- is refused with a message that says the template was retired, instead of the
- generic "Unknown template" error that reads as a typo.
-- The GitHub tarball-fetch path that served the remote templates is removed
- along with its `tar` dependency; nothing else reached it.
-
-Note this corrects the catalog at HEAD only. Already-published versions keep
-advertising the retired templates until a new version of `create-objectstack`
-is released.
diff --git a/.changeset/retirement-sentence-states-what-migrate-meta-does.md b/.changeset/retirement-sentence-states-what-migrate-meta-does.md
deleted file mode 100644
index 04d354040e..0000000000
--- a/.changeset/retirement-sentence-states-what-migrate-meta-does.md
+++ /dev/null
@@ -1,49 +0,0 @@
----
-"@objectstack/spec": patch
-"@objectstack/lint": patch
----
-
-fix(spec): the retirement prescriptions state what `os migrate meta` actually does (#9529)
-
-Every `retiredKey()` prescription whose surface an ADR-0087 conversion covers
-closed with a maintainer-ruled sentence (2026-08-09, #6856):
-
-> Run `os migrate meta --from N` to rewrite existing sources automatically.
-
-The command has never rewritten an authored source file. It replays the
-conversion chain over the loaded stack **in memory**, prints the attributed
-mechanical change list (`Applied N mechanical change(s)`, one line per site as
-`path: from → to (conversionId)`), and writes exactly one file — the `--out`
-JSON snapshot, when you ask for it. Every write site in
-`packages/cli/src/commands/migrate/meta.ts` is that snapshot; there is no
-`--write` / `--fix` / in-place flag. So an author who followed the prescription
-got the chain replayed, a printed diff and optionally a JSON document in a shape
-their per-artifact `.ts` modules are not written in — and then still edited every
-file by hand, with nothing in the message saying so.
-
-Under the maintainer's ruling of 2026-08-18 the sentence is withdrawn in favour
-of an honest one, class-wide:
-
-> Run `os migrate meta --from N` to list the mechanical edits for existing
-> sources; apply them by hand.
-
-The partial-value conversions keep their two-clause shape, reworded the same way
-(`… to list the mechanical edits for the \`1y\` case; the other durations are
-reported for you to re-state.`). Behaviour is unchanged in both packages — this
-is message text only, and no accept/reject verdict moves.
-
-The claim is withdrawn from every shipped site, not only the canonical sentence:
-the variant phrasings in tombstone and conversion-registry prose ("rewrites
-author sources", "rewrites it for you", "only `os migrate meta` rewrites
-sources") go with it, as do the upgrade-path statements in the hand-written docs
-(`upgrading.mdx` now carries the same "does not rewrite your source files" fact
-the `objectstack-upgrade` skill already told operators). The class-wide pin
-`packages/spec/src/shared/retired-key-migrate-sentence.test.ts` moves in
-lockstep and now holds **both** directions: the new sentence is required where a
-prescription names the command, and the withdrawn claim is a hard failure
-wherever it reappears — including in a prescription that spells the bare command
-without `--from N`, which the sentence-shape check alone would not have seen.
-
-The in-place AST codemod that would make the original claim true is commissioned
-separately for v18 (#9591); when it lands, the sentence may be restored by
-editing that one pin in the same PR.
diff --git a/.changeset/retry-attempt-pause-suspend-arm.md b/.changeset/retry-attempt-pause-suspend-arm.md
deleted file mode 100644
index 485da43f97..0000000000
--- a/.changeset/retry-attempt-pause-suspend-arm.md
+++ /dev/null
@@ -1,67 +0,0 @@
----
-"@objectstack/service-automation": patch
-"@objectstack/runtime": patch
----
-
-fix(service-automation): a retry attempt that PAUSES is a durable pause, not a failed attempt — `executeWithoutRetry` gets the ADR-0019 suspend arm (#9510)
-
-`execute()`'s catch tests the suspend signal FIRST, and that arm is what makes
-ADR-0019's durable pause work: it snapshots the live variables, calls
-`persistSuspendedRun`, records a `paused` log entry and returns
-`{ success: true, status: 'paused', runId }`.
-
-`executeWithoutRetry()` — the method `retryExecution` re-runs the flow through on
-**every** retry attempt — had no such arm. A `FlowSuspendSignal` thrown on a
-retry attempt fell into the generic failure path, and four things were lost at
-once:
-
-1. `persistSuspendedRun` never ran, so **the continuation was never stored** and
- the run could not be resumed by anyone, ever;
-2. the run log recorded `failed` for a run that asked to pause;
-3. the caller got `status: 'failed'`, with the suspend signal stringified into
- `error` (`FlowSuspendSignal` is not an `Error`);
-4. `retryExecution` reads only `result.success`, so the pause counted as one more
- failed attempt: the loop burned the rest of the budget, and every further
- attempt re-entered the pausing node and orphaned another suspension.
-
-Only a LATER attempt is exposed — `execute()` handles the first one correctly,
-and a flow reaches `retryExecution` only after a failure. The reachable shape is
-the ordinary one: `errorHandling.strategy: 'retry'` on a flow whose flaky
-HTTP/connector call is followed by an `approval` or `screen` node.
-
-**⚠️ Runs already lost to this defect are NOT recoverable.** Nothing was written
-for them — no `sys_automation_run` row, no in-memory suspension — so there is no
-continuation to rehydrate and no repair, here or later, can bring one back. The
-run log holds a `failed` entry naming the flow and the trigger; those runs have
-to be triggered again. What this change fixes is every run from here on.
-
-**The repair is a restoration of a stated contract on a path that never got it,
-not a new capability.** `AutomationResult.status: 'paused'` and ADR-0019 already
-describe exactly this behaviour, and `execute()`'s own arm already implements it;
-the retry path simply never received it. The alternative — refusing
-`strategy: 'retry'` combined with a pausing node at authoring time — was
-considered and rejected: it over-refuses (a pausing node can sit on a branch the
-retrying path never reaches), under-refuses (a pausing node behind a runtime
-condition is not statically decidable), and would ban the one combination authors
-most reasonably reach for.
-
-**The cost, and what was done about it.** Lifting the arm makes `retryExecution`
-able to return a NON-TERMINAL result, and both of its readers were taught the
-third state explicitly rather than left to a branch that happens to fall through:
-the retry loop returns a paused attempt because it PAUSED (tested on `status`,
-before the `success` check that means "this attempt succeeded"), and the trigger
-route answers it from its own arm. The retry accounting is untouched — a
-genuinely failing attempt still consumes one, `maxRetries` still bounds the loop,
-and the loop stops only because the attempt did not fail.
-
-**Both routes give one answer**, pinned as an equality rather than verified in
-isolation: a pause on attempt 1 and a pause on attempt 3 produce the same engine
-result and the same wire response, so no caller can tell which attempt paused.
-
-Two adjacent gaps were measured out of this work and filed rather than absorbed:
-a retry attempt runs with a smaller variable environment than the first (#9704),
-and a flow's declared retry policy stops applying once a run pauses (#9705) —
-the latter being the measured answer to "what happens to the retry budget when a
-paused run is resumed and then fails": neither inherited nor fresh, because the
-resume path has no retry loop at all. Both are pinned as today's behaviour so
-neither can change by accident.
diff --git a/.changeset/retry-attempt-variable-environment.md b/.changeset/retry-attempt-variable-environment.md
deleted file mode 100644
index ff9d56c130..0000000000
--- a/.changeset/retry-attempt-variable-environment.md
+++ /dev/null
@@ -1,25 +0,0 @@
----
-'@objectstack/service-automation': patch
----
-
-fix(service-automation): a retry attempt now runs with the same variable environment as the first
-
-`executeWithoutRetry()` — the method the retry loop re-runs a flow through on every
-attempt — seeded only the flow's declared variables and `$record`, while the first
-attempt also binds `record` plus the triggering record's flattened fields, `previous`,
-`$runId`, `$flowName` and `$flowLabel`. Every retry attempt therefore ran in a strictly
-smaller environment than attempt 1.
-
-Because conditions are strict CEL, where reading an unbound name aborts the predicate
-rather than yielding `false`, this was user-visible exactly where retry is most used —
-`errorHandling.strategy: 'retry'` on a record-change flow:
-
-- a start condition or edge predicate reading `previous` (the create-vs-update
- discriminator) aborted on the retry, so the retry failed for a reason the first attempt
- never hit — reading as a flaky flow rather than a defect;
-- a bare reference to a triggering-record field (`status`, `budget`) aborted for the same
- reason;
-- a pausing node (e.g. Approval) reached on a retry attempt saw no `$runId`, so the
- external state it minted could not be mapped back to the run for resume (ADR-0019).
-
-Both methods now seed through one shared chokepoint. First-attempt behaviour is unchanged.
diff --git a/.changeset/revert-commit-stored-type-preflight.md b/.changeset/revert-commit-stored-type-preflight.md
deleted file mode 100644
index f049f3ebfa..0000000000
--- a/.changeset/revert-commit-stored-type-preflight.md
+++ /dev/null
@@ -1,67 +0,0 @@
----
-"@objectstack/metadata-protocol": patch
----
-
-fix(metadata-protocol): `revertCommit` refuses a non-canonical stored type on its restore limb, with the wire-visible code its sibling doors already give (#9174)
-
-`isNonCanonicalStoredType` (#8908) names a six-member class of AT-REST spellings
-whose type the manifest-collection map omits — `fields`, `seeds`,
-`external_catalogs`, `externalCatalogs`, `translations`, `email_templates`. Rows
-of that class are pre-#7894 residue: `PUT /meta/fields/…` answered 200 and
-persisted before the `/meta` boundary fold closed that door, and nothing
-rewrites them on upgrade.
-
-Two doors that consume an at-rest `type` already answer for the class **by
-name**: `publishPackageDrafts` refuses with a `failed[].code` of
-`STORED_TYPE_NOT_CANONICAL` (#8908), and `migrateStoredMetadata` reports the row
-`skipped` with the same reason stated in full (#8957). `revertCommit` is the
-third consumer, and it is the producer #9111 traced and left explicitly
-unguarded.
-
-**Measured at HEAD before choosing a shape**, end to end over the real
-`SysMetadataRepository` on an unscoped kernel, per limb:
-
-- **restore limb** (`existedBefore: true`) — answered
- `{ success: true, revertedCount: 1, failed: [] }` with
- `reverted[0].action === 'restored'`, called `registerItem` **zero** times, and
- left one line of server-side stderr as the only trace:
- `[Protocol] registry write-through failed for fields/showcase_task.title:
- [registry_type_not_canonical] …`. The receipt claims the pre-commit body is
- what the platform now serves; for this class it cannot be — #9111's mint door
- refuses the entry and boot refuses it too, so the restored body reaches no
- reader at all.
-- **soft-remove limb** (`existedBefore: false`) — answered
- `{ success: true, action: 'removed' }`, the row **gone** from `sys_metadata`,
- no warning emitted and no registry key touched. Nothing about that outcome is
- wrong.
-
-**The shape is `saveMetaItem`'s refusal**, carried on this door's existing
-per-item `failed[]` channel — the same one `VERSION_NOT_FOUND`, `ITEM_LOCKED`
-and `NOT_OVERRIDABLE` already ride. No new receipt surface and no new error
-code: `STORED_TYPE_NOT_CANONICAL` is already this package's and already in the
-error-code ledger. The test that separates it from `migrateStoredMetadata`'s
-decline is whether the door can do what it *promises* for this row: the migrate
-pass declines because rewriting a stored type spelling is an identity move and
-out of its reach entirely, so `skipped` must not poison `storedMigrationClean`
-for a scan that runs forever; here the write is squarely in reach and still
-delivers none of what `restored` promises, which is `saveMetaItem`'s case. So it
-is refused, and `success` goes false — the commit the operator asked to undo was
-not undone, and a one-shot operator action has no forever to poison.
-
-**The soft-remove limb is deliberately outside the gate.** It performs its
-promise exactly and completely, and the removal is the one action that makes
-this residue smaller; refusing it would answer `success: false` for a revert
-that fully succeeded and would hand back an instruction ("drop the `fields`
-row") naming the very operation it had just declined to perform.
-
-**Nothing is folded.** The refusal writes no audit row and no commit record, and
-carries the stored spelling into `failed[]` verbatim, so #9161's ruling — the
-caller's spelling reaches the ledger keys unfolded, and `AUDIT_TYPE_NOT_CANONICAL`
-fires loudly when it is wrong — is untouched in both directions. A refused item
-is simply absent from `reverted[]`, so the append-only revert commit built from
-it never claims an undo that did not happen.
-
-The predicate stays the narrow at-rest one rather than the complete
-`canonicalMetaType(t) !== t`: `objects`/`views` fold in the manifest map, so the
-restore limb already hands the write-through a canonical key and those rows are
-not this defect — widening would change a wire-visible `failed[].code` for them.
diff --git a/.changeset/rollback-canonical-type-fold.md b/.changeset/rollback-canonical-type-fold.md
deleted file mode 100644
index 4627ddda9c..0000000000
--- a/.changeset/rollback-canonical-type-fold.md
+++ /dev/null
@@ -1,62 +0,0 @@
----
-"@objectstack/metadata-protocol": patch
----
-
-fix(metadata-protocol): `rollbackMetaItem` routes through the canonical type fold, closing an ADR-0010 `_lock` a plural URL spelling could address around (#8819)
-
-`rollbackMetaItem` is the **eighth** `/meta` entry point on the
-`POST /api/v1/meta/:type/:name/rollback` URL family, and it was the last one
-still deriving its type key from `PLURAL_TO_SINGULAR` — the
-MANIFEST-COLLECTION map #7894 moved this boundary off — instead of
-`canonicalizeMetaRequestType`. The other seven fold; this one did not.
-
-**The half of that asymmetry that was not fail-closed is the lock.**
-`assertLockAllowsWrite` delegates to `getEffectiveLock`, whose artifact limb
-folds and whose **overlay limb queries `sys_metadata` with the raw `type`**. The
-rollback passed the caller's spelling to the gate while every row operation
-below it used the folded key. So for a manifest-present type, a rollback
-addressed `/meta/views/case_grid/rollback` looked the `_lock` up under a `type`
-no row carries, got `'none'` back — which is not a neutral value but the verdict
-"the author declared no protection" (#5706) — and then restored the history body
-against the folded key, which resolves the protected row perfectly. A lock gate
-addressable around from the wire, on the verb that overwrites the active body.
-
-**The severity window is narrow and is not rounded up here.** It needs an
-environment kernel (`assertLockAllowsWrite` opens with
-`if (this.environmentId === undefined) return null`, skipping the gate wholesale
-otherwise) **and** a lock carried by a **stored overlay row** rather than a
-packaged artifact — the artifact limb folds, so an artifact `_lock` was already
-found under either spelling. Inside that window the write landed.
-
-The fold also reaches three things that were merely incoherent rather than
-unsafe: the revertability tier (`isOverlayAllowed` / `isRuntimeCreateAllowed`)
-took the permissive **plugin** branch for the four manifest-absent types
-(`field`, `seed`, `external_catalog`, `translation`); and the
-`[not_overridable]` refusal, both ADR-0010 audit rows and both receipt sentences
-reported the **caller's** spelling for a row written under the canonical one.
-`recordMetadataAudit` re-folds internally through `PLURAL_TO_SINGULAR`, which
-covers a manifest-present plural and misses the four manifest-absent ones — so
-folding at the boundary is what makes the audit trail agree with the write for
-both classes.
-
-Placed after the existing `toVersion` envelope guard rather than at the very top
-of the method: that is the position `saveMetaItem` documents for this exact pair,
-naming this method's opening guard its structural twin — a malformed request
-envelope is refused before its type key is canonicalised, and both refusals are
-`[invalid_request]`/400 either way.
-
-**What this does not do.** `getEffectiveLock`'s overlay limb still queries the
-raw `type`. Folding it there would close the class at the producer for every
-present and future caller, which is the contract-first shape — but it is a
-shared gate whose blast radius wants its own measurement, so it is deliberately
-left open as its own card rather than ridden in here.
-
-Pinned in `packages/objectql/src/protocol-publish-canonical-fold.test.ts` as
-group D, driving the real `ObjectQL` / protocol / `SysMetadataRepository` over an
-in-memory driver on an environment kernel: the canonical spelling is refused by
-the lock, the plural spelling is refused by the **same** lock, and — the clause
-that matters, since the first two can both pass while the write still lands —
-the protected active body is **unchanged** afterwards. A positive control runs
-the identical plural call with the lock removed and asserts it really does
-restore the earlier body, so the group cannot pass by being unable to roll back
-at all.
diff --git a/.changeset/runtime-config-product-stage.md b/.changeset/runtime-config-product-stage.md
deleted file mode 100644
index a6bfd1a85b..0000000000
--- a/.changeset/runtime-config-product-stage.md
+++ /dev/null
@@ -1,57 +0,0 @@
----
-"@objectstack/cloud-connection": minor
----
-
-fix(runtime-config): `OS_PRODUCT_STAGE` / `branding.stage` actually reaches `/api/v1/runtime/config`, so the documented preview-badge switch stops being a no-op (#9252)
-
-
-
-Running `examples/app-showcase` with `OS_PRODUCT_STAGE=ga objectstack dev` left
-the Console's "Preview" chip on screen. `RuntimeConfigPlugin` never emitted
-`branding.stage`, so objectui's `PreviewBadge` — which reads exactly that key —
-never saw the value, and the switch objectui's app-shell README presents as the
-operational way to hide the badge did nothing at all.
-
-**Nobody implemented it, in either distribution.** The card guessed the knob was
-"honored only by the cloud distribution"; measured with a control first, so the
-zeros are a reading rather than a broken search:
-
-| probe | result |
-|---|---|
-| `OS_PRODUCT_STAGE`, framework repo-wide | 0 hits |
-| `OS_PRODUCT_STAGE` / `branding.stage` / `PlatformStage`, cloud repo-wide | 0 hits |
-| control: `OS_PRODUCT_NAME`, cloud repo | 9 hits |
-| control: files mentioning `branding`, cloud repo | 18 files |
-
-So this is the declared-but-unenforced trap in its purest form: a documented
-operator knob with no producer anywhere. Emitting the key restores an
-already-declared contract rather than widening a surface — no request that is
-accepted today becomes rejected, or vice versa.
-
-**Resolved in the plugin, not threaded through the CLI.** Both halves of the
-documented interface name this plugin (`OS_PRODUCT_STAGE` **or**
-`new RuntimeConfigPlugin({ stage })`), every sibling branding key already
-resolves `config.X ?? OS_X` in the same constructor, and — decisively — the
-card's own repro constructs its **own** `RuntimeConfigPlugin` in
-`examples/app-showcase/objectstack.config.ts`, which wins over the CLI's by
-plugin name. A value threaded through `Serve.RUNTIME_CONFIG_OPTIONS` would have
-left the reported repro still broken. The cloud distribution inherits the fix
-for free: its `RuntimeConfigPlugin` extends this one and spreads its config into
-`super()`, so there is one mechanism answering this question, not two.
-
-**The value space is closed** — `'preview' | 'beta' | 'ga'`, mirroring the
-`PlatformStage` union the Console branches on (exported as `PlatformStage`). An
-unrecognised value is refused and named in a mount-time `warn` listing the
-accepted spellings, never forwarded: the SPA discards off-contract values
-anyway, so a passthrough would recreate this bug's exact shape — an operator
-sets the knob, nothing happens, nothing is said.
-
-**Unset stays absent.** No `stage` key at all, rather than an empty string or a
-default invented server-side, so the Console keeps applying its own documented
-`'preview'` default and nothing that works today changes. The regression proof
-asserts that direction on **key presence** (`hasOwnProperty`), not
-`toBeUndefined()` — `{ stage: undefined }` satisfies the latter while being a
-present property that survives `structuredClone` and shows up in `Object.keys`.
diff --git a/.changeset/runtime-dispatcher-discovery-envelope.md b/.changeset/runtime-dispatcher-discovery-envelope.md
deleted file mode 100644
index 574b2493d8..0000000000
--- a/.changeset/runtime-dispatcher-discovery-envelope.md
+++ /dev/null
@@ -1,28 +0,0 @@
----
-"@objectstack/runtime": minor
----
-
-feat(runtime): the dispatcher's two discovery bodies join the response envelope (#9813)
-
-
-
-`GET /.well-known/objectstack` and the REST-less fallback `GET {prefix}/discovery`
-answered `{ data: {discovery} }` with no `success` flag — one key short of the
-declared `BaseResponseSchema` envelope. They now answer
-`{ success: true, data: {discovery} }`.
-
-This inherits the #9436 maintainer ruling (2026-08-18, option A) on the hono
-adapter's identical discovery bodies, with its reason intact: machine-read
-discovery surfaces — SDK `connect()` fallback probes, codegen, AI clients — are
-the envelope's core constituency, and the migration is one additive key. It is
-deliberately not #9389's pre-auth exemption, which is a closed list of SPA-read
-shell-bootstrap surfaces these bodies are not on. Readers that unwrapped
-`body.data` keep working unchanged; envelope-aware readers that discriminate on
-`success` now unwrap these routes correctly.
diff --git a/.changeset/runtime-publish-drafts-flip-announce-driver-text.md b/.changeset/runtime-publish-drafts-flip-announce-driver-text.md
deleted file mode 100644
index 13f5ca1f4a..0000000000
--- a/.changeset/runtime-publish-drafts-flip-announce-driver-text.md
+++ /dev/null
@@ -1,45 +0,0 @@
----
-"@objectstack/runtime": patch
----
-
-fix(runtime): `publish-drafts` no longer discloses driver or subscriber text on `unhideError` / `rebindError` (#8516)
-
-`POST /api/v1/packages/:id/publish-drafts` answered, on a **200**:
-
-```json
-{ "success": true, "data": {
- "unhideError": "SQLITE_ERROR: no such table: sys_metadata",
- "rebindError": "TypeError: Cannot read properties of undefined (reading 'triggers') at AutomationPlugin.rebind (/srv/objectstack/packages/services/service-automation/dist/index.js:412:31)" } }
-```
-
-These are the two remaining producers on the response whose `seedApplied` field
-#8443 converted — the ADR-0045 visibility flip and the `metadata:reloaded`
-announce. Both ride a success body as **data**, so no HTTP boundary's 5xx
-message withhold can reach them; the disclosure had to be closed at the
-producer. Both were driven for real before being changed, and both reproduced.
-
-Both now follow the rule already in force next door: a caught sentence is
-quoted only when the error **declared** itself a client-facing refusal (4xx
-`status`, ADR-0112); anything else gets the stable sentence the field could
-already carry, and the original goes to the server log. The rule is imported
-from `@objectstack/metadata-protocol` (`clientFacingFailureText`), not restated
-locally.
-
-**Both halves of the rule, because the two sites started in different states.**
-The flip already logged its cause in full at `error` with an operator remedy, so
-only its payload changed. The announce had **no log line at all** — withholding
-alone would have converted an over-disclosure into a silent failure, so it gains
-one at `warn`, naming the cause, the concrete consequence (a newly published
-record-triggered flow does not bind its trigger until the process restarts) and
-the fix (re-run the idempotent publish, or restart). `warn` rather than `error`
-because nothing that claimed to persist failed to: the drafts are published and
-the flip is stored, and an unbound trigger is AGENTS.md's own worked example of
-a functional degradation — the level the sibling announce of this same event
-already uses.
-
-**Authoring feedback is preserved, not blanked.** The flip's authored refusals
-all declare 4xx (`ITEM_LOCKED`, `NOT_OVERRIDABLE`,
-`OBJECT_OVERLAY_PACKAGE_MISMATCH`, …), so a locked or non-overridable app still
-tells its publisher which app and why, verbatim — and the `unhiddenApps`
-half-flip report beside it is untouched. A subscriber that declares a 4xx
-refusal is quoted by the same positive list.
diff --git a/.changeset/scaffold-runtime-image-pinned.md b/.changeset/scaffold-runtime-image-pinned.md
deleted file mode 100644
index 822ba6b863..0000000000
--- a/.changeset/scaffold-runtime-image-pinned.md
+++ /dev/null
@@ -1,49 +0,0 @@
----
-"create-objectstack": patch
----
-
-fix(create-objectstack): the scaffolded Dockerfile pins the runtime image to the CLI that builds the artifact, instead of `latest` under a comment saying to pin (#9017)
-
-`src/templates/blank/Dockerfile` shipped `FROM ghcr.io/objectstack-ai/objectstack:latest`
-directly beneath a comment instructing the reader to "pin the tag to the
-`@objectstack/cli` version in your package.json so the runtime matches the CLI that built
-the artifact" — an instruction the scaffold itself did not follow. Every app made with
-`npx create-objectstack` shipped that contradiction from day one, and `docker/README.md`'s
-tag table already scopes `latest` to quick starts while documenting `X.Y.Z` as the
-production pin.
-
-Measured on scaffolded output rather than the template's bytes, before the fix:
-
-```
-emitted package.json cli range : ^17.0.0
-emitted Dockerfile FROM : FROM ghcr.io/objectstack-ai/objectstack:latest
-agreement (tag vs cli range) : DISAGREE
-```
-
-**The tag is resolved after `install`, from the installed CLI — not from the generated
-`package.json`.** That file carries a caret RANGE, and the two are not interchangeable:
-npm resolves `^17.0.0` to the newest 17.x, so pinning the range's floor would ship a
-runtime image *older* than the CLI that built the artifact — breaking the same promise in
-a new way. The rolling `:17` tag does match the range's float window but is exactly what
-the tag table tells production not to use. The resolved version is the only value that
-makes the sentence true, and it is the rule the repo already applies for this purpose in
-`.github/workflows/scaffold-e2e.yml` ("Pin the runtime's CLI to the SAME version the
-generated project actually resolved to — NOT a hardcoded `latest`").
-
-**Both halves move together.** Pinning the line while leaving an imperative to pin by hand
-would relocate the contradiction rather than remove it, so the comment above the `FROM`
-line is replaced in the same rewrite. With `--skip-install` there is no resolved version:
-the tag stays `latest` and the comment keeps telling the reader to pin — which is true on
-that path, because there the user really must do it by hand.
-
-The regression proof asserts on **scaffolded output**, never on the template: it scaffolds
-with the real copy/sync/pin path, plants an installed CLI whose version is deliberately
-*not* the range's floor (the normal case, and the one that a package.json-derived tag
-would get wrong), and checks the emitted `FROM` tag against the emitted `package.json`
-range with a satisfies-check rather than equality.
-
-`.github/workflows/scaffold-e2e.yml` now reads the tag it builds its local runtime image
-under **out of the generated Dockerfile** instead of hardcoding `:latest`. Those were two
-hand-matched literals; had they skewed, Docker would have quietly pulled the last
-published image instead of the one built from this checkout, and the job's own stated
-hermeticity would have been false while it stayed green.
diff --git a/.changeset/searchable-fields-anchor-provenance.md b/.changeset/searchable-fields-anchor-provenance.md
deleted file mode 100644
index e6563eab62..0000000000
--- a/.changeset/searchable-fields-anchor-provenance.md
+++ /dev/null
@@ -1,53 +0,0 @@
----
-"@objectstack/lint": minor
----
-
-fix(lint): ask the provenance question at the fifth blanket-`SYSTEM_FIELDS` read site — `searchableFields` (#8404)
-
-`validate-searchable-fields.ts` judged a declared `searchableFields` entry
-against the object-independent `SYSTEM_FIELDS` union, exactly as the four
-filter/page-binding rules did before #8340 wired them to the per-object index.
-Both of its gates were correct about EXISTENCE and structurally blind to
-PROVENANCE: `:345` keeps `searchable-field-unknown` silent for any name in the
-union, and `resolveAllowedSet` goes further — it manufactures a stub meta for
-such an entry so it survives the resolution's existence filter exactly as it
-does at runtime.
-
-On an ADR-0015 `external` object the platform registers its injected anchors
-(`owner_id`, `organization_id`, the audit family, …) and provisions no storage
-behind them (#7865 / #8116), so:
-
-```
-searchableFields: ['name', 'owner_id'] // external object
-```
-
-linted clean, the stub kept the entry in the resolved allow-list, and the
-view's `$searchFields` narrowing then scanned a column empty on every record —
-#4830's own failure mode (a narrower search than declared, silently) reached by
-a different route.
-
-A new `searchable-field-unprovisioned` rule now warns on such an entry, on the
-object's own canonical set and on a list view's narrowing alike, reusing
-`unprovisionedAnchorCause` / `unprovisionedAnchorHint` so the sentence matches
-the four #8340 rules verbatim rather than becoming a second copy (#4830). WARN,
-never gating, per #4330's cost asymmetry: the remote schema is not visible to
-this pass, so the finding describes a degradation rather than a refusal.
-
-**The `:239` stub is KEPT.** It is not incidental — it is what makes the linter's
-resolution agree with the runtime's, which resolves the declared branch against
-the registry field map. Measured by disabling it: the existing "keeps runtime
-parity when the object declares system columns searchable" test goes red
-(`expected [] to have a length of 1 but got +0`), because the declaration
-existence-filters to empty and resolution falls through to the auto-default.
-Dropping it would have been a behaviour change dressed as a warning.
-
-The warning is emitted per declared entry in the checker's entry loop, never
-inside `resolveAllowedSet` — that helper reads the OBJECT's declaration and runs
-once per narrowing, so warning there would repeat one object-level fact for
-every view and attribute it to the view's path.
-
-`checkSearchableFieldList` takes the index as an OPTIONAL trailing parameter,
-the same shape #8340 gave `checkFieldRefs`: its absence means the caller did not
-build the index and the provenance question goes unasked — the previous
-behaviour, preserved for out-of-repo callers (cloud graph-lint, the AI authoring
-path). Both in-repo callers pass it.
diff --git a/.changeset/searchall-title-canonical-namefield.md b/.changeset/searchall-title-canonical-namefield.md
deleted file mode 100644
index 877e47b440..0000000000
--- a/.changeset/searchall-title-canonical-namefield.md
+++ /dev/null
@@ -1,36 +0,0 @@
----
-"@objectstack/metadata-protocol": patch
----
-
-fix(metadata-protocol): global search titles a hit from the canonical `nameField`, not only the deprecated `displayNameField` alias (#8786)
-
-`searchAll` — the global-search (⌘K) palette — resolved a hit's title from a
-candidate list that opened with `obj.displayNameField` **alone**. Under
-ADR-0079 `nameField` is the canonical primary-title pointer and
-`displayNameField` is the deprecated alias, so this was the one consumer a
-canonical designation could not reach.
-
-It is reachable rather than theoretical because `provisionPrimary` — the
-ADR-0079 designation seat the SchemaRegistry runs on every object at
-registration — stamps `nameField` **only** and never the alias. An object that
-declares its primary title canonically, without also carrying the deprecated
-alias, produced `undefined` for that entry, the entry was filtered out of the
-candidate list, and the title fell through to `String(row.id)`: the palette
-showed a raw record id where the object's own declared, populated title
-existed.
-
-Impact was bounded to objects whose primary title is **outside**
-`name` / `full_name` / `title` / `subject` / `label` / `company` — anything in
-that conventional list already resolved through the later entries, which is why
-this stayed invisible. An object declaring `nameField: 'company_name'` now
-titles its hits `Acme Industrial` instead of `acc_1`.
-
-The fix reads the precedence the rest of the platform already spells —
-`obj.nameField ?? obj.displayNameField` — matching `resolveDisplayField`
-(`@objectstack/spec`), the #4254 ingress gate, and this same function's
-search-field resolution 44 lines below. The deprecated alias is still honored
-on its own; only objects that carry **both** pointers naming **different**
-fields see a precedence change, and no such object exists in this repo (every
-one that carries both spells them identically).
-
-Presentation only: which rows come back is untouched.
diff --git a/.changeset/seed-loader-name-probe-asked-not-assumed.md b/.changeset/seed-loader-name-probe-asked-not-assumed.md
deleted file mode 100644
index 17eda75a39..0000000000
--- a/.changeset/seed-loader-name-probe-asked-not-assumed.md
+++ /dev/null
@@ -1,47 +0,0 @@
----
-"@objectstack/metadata-protocol": patch
----
-
-fix(metadata-protocol): SeedLoader asks the registry for a `name` column before probing it — no more hundreds of provoked `INVALID_FILTER` refusals per seeded boot (#9071)
-
-`SeedLoaderService.resolveFromDatabase()` resolves a reference authored as a
-natural key by walking a probe chain: the target dataset's declared
-`externalId`, then the historical `name` default, then the internal `id`. The
-`name` leg was spelled **unconditionally** — including on objects that have no
-`name` column at all.
-
-On those objects the probe is not a cheap miss. The driver **refuses** it:
-
-```
-[sql-driver] INVALID_FILTER — Filter on 'name' names a column that object
-'crm_contact' has no column for, so the predicate never ran.
-ERROR Find operation failed {"object":"crm_contact", …}
-```
-
-and it is right to. A predicate naming a column the object does not have never
-ran, so answering "no rows" would be a lie (ADR-0110 D3 — a miss and a fault are
-different facts). One refusal is raised per reference value, per pass: a real
-`serve` boot with a 342-row seed emitted **hundreds of ERROR-level
-`Find operation failed` lines**, on every seeded boot and every per-organization
-replay, each reading exactly like a real failure that everyone downstream has to
-learn to ignore.
-
-**The fix is on the asking side, not the answering side.** The driver's refusal
-is untouched — not caught more quietly, not downgraded, not filtered out of the
-log. Instead the loader now asks the metadata registry whether the target
-declares a `name` column, through the same `resolveObjectDefinition` resolver it
-already builds the reference graph from (metadata service first, then the
-engine's own schema registry), and drops the leg when the answer is no.
-
-Which probe answers cannot change: on an object with no `name` column that leg
-could only ever throw, never match. The guard covers the leg in **both** of its
-positions — the fallback, and the first position it occupies when a referenced
-target carries no dataset in this load and keeps the metadata-level `name`
-default.
-
-**An unknown is not a denial.** When neither the metadata service nor the
-engine's schema registry can describe the object, the leg is kept — the
-historical behaviour — rather than narrowed on a fact nobody established. The
-answer is memoised per `load` (the question is asked once per unresolved
-reference value, hundreds per boot) and re-asked on the next one, since a
-publish between two loads can add the very column it is about.
diff --git a/.changeset/seed-tenancy-autonumber-split.md b/.changeset/seed-tenancy-autonumber-split.md
deleted file mode 100644
index dbca6887e2..0000000000
--- a/.changeset/seed-tenancy-autonumber-split.md
+++ /dev/null
@@ -1,29 +0,0 @@
----
-"@objectstack/metadata-protocol": patch
-"@objectstack/runtime": patch
----
-
-Stamp seeded rows with the install's organization so one object runs one autonumber scope (#8686)
-
-Seed writes and API writes disagreed about tenancy. Seed data is loaded during
-app start, before any human user exists, so the seed loader had no organization
-to stamp and its rows landed `organization_id = NULL`; API writes carried the
-signed-in user's organization. The SQL driver keys its autonumber counter by
-exactly that column (`__global__` when NULL), so a single object ran two
-independent counters — and the uniqueness index is partitioned by the same key
-(`COALESCE(organization_id, '__global__'), `), so the duplicates the
-second counter minted were invisible to the constraint. On a single-tenant
-install seeded with `CASE-00001..38`, the first four API creates returned
-`CASE-00001..4` again: four duplicated values on a field declared `unique`, with
-201s and no warning.
-
-Seed writes now carry the organization the same way API writes do. The moment an
-install's organization first exists, untenanted seed rows are adopted into it and
-the `__global__` counter is merged into the organization-scoped one, so the
-`__global__` pseudo-tenant stops acting as a peer of a real organization. Existing
-installs are repaired by a one-shot boot-time backfill, guarded to single-tenant
-installs; a multi-tenant install where a split is detected is never guessed at —
-the backfill skips and logs the condition and the remedy. Business identifiers
-that were already minted twice are reported for the operator, never silently
-renumbered. Platform namespaces (`sys_`/`cloud_`/`ai_`) stay global, exactly as
-the seed loader already treats them.
diff --git a/.changeset/seed-tenancy-mysql-dialect.md b/.changeset/seed-tenancy-mysql-dialect.md
deleted file mode 100644
index 8922cbcb12..0000000000
--- a/.changeset/seed-tenancy-mysql-dialect.md
+++ /dev/null
@@ -1,30 +0,0 @@
----
-"@objectstack/metadata-protocol": minor
-"@objectstack/runtime": patch
----
-
-fix(metadata-protocol): compile the seed-tenancy backfill's statements for the connected dialect, so they run on MySQL (#9381)
-
-`seed-tenancy-backfill.ts` quoted every identifier the ANSI way (`"x"`) on every
-dialect. MySQL does not run with `ANSI_QUOTES` — measured on a live MySQL 8.0.46,
-whose `sql_mode` is
-`ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION`,
-and nothing in `driver-sql` sets one — so `"x"` is a string literal there and all
-seven statements failed with `ER_PARSE_ERROR`. The repair for #8686 therefore
-never ran on MySQL, silently: a migration must not fail a boot, so every call site
-turns the failure into a warning and the symptom was a skipped repair in the log
-rather than an error.
-
-The statements are now compiled for the driver actually connected, and the seam
-carries the dialect with it (`resolveSeedTenancySeam` returns `{ exec, client }`;
-`backfillSeedTenancy` takes that pair) so a caller cannot lose it. Two further
-MySQL-only defects in the same statements, both measured on the same server, are
-fixed with it: `last_value` is a reserved word on MySQL 8.0 and is now quoted
-wherever it is unqualified, and the stamp's exclusion sub-SELECTs go through a
-derived table because MySQL refuses `UPDATE t … (SELECT … FROM t)` with
-`ER_UPDATE_TABLE_USED`. SQLite and PostgreSQL keep the exact ANSI spelling they
-had (both re-verified live).
-
-`resolveSeedTenancyExec` stays exported and unchanged for callers that resolve the
-dialect themselves; `backfillSeedTenancy` now takes the seam object instead of a
-bare exec.
diff --git a/.changeset/serve-banner-artifact-row.md b/.changeset/serve-banner-artifact-row.md
deleted file mode 100644
index 5b1ee463e1..0000000000
--- a/.changeset/serve-banner-artifact-row.md
+++ /dev/null
@@ -1,23 +0,0 @@
----
-"@objectstack/cli": patch
----
-
-fix(cli): `os serve`'s ready banner no longer names a config file that was not read (#8978)
-
-On an `OS_ARTIFACT_URL` boot (#8368) the `objectstack.config.ts` in cwd is
-deliberately never executed — the boot diagnostics say so — but the ready
-banner's `Config:` row still printed it, because `relativeConfig` was derived
-from `args.config` before the artifact-fallback branch was decided and handed
-to `printServerReady` unconditionally. The plain artifact-fallback path (no
-config authored, booting from the `/dist/objectstack.json` convention or
-`OS_ARTIFACT_PATH`) had the same defect one level worse: the row named a
-config file that does not exist on disk at all.
-
-The banner is the surface an operator reads to answer "what is this container
-actually running" — naming what did NOT boot points them at the wrong app.
-
-`serve` now reports the resolved artifact's already-redacted `display` string
-in an `Artifact: … (OS_ARTIFACT_URL)` row when `OS_ARTIFACT_URL` pinned one,
-omits the row on the other artifact-fallback paths (no safely-redacted value
-is in hand there), and reports the authored config exactly as before on the
-ordinary config-boot path.
diff --git a/.changeset/serve-multi-node-cap-advisory.md b/.changeset/serve-multi-node-cap-advisory.md
deleted file mode 100644
index f0437f3753..0000000000
--- a/.changeset/serve-multi-node-cap-advisory.md
+++ /dev/null
@@ -1,33 +0,0 @@
----
-'@objectstack/cli': patch
----
-
-`serve`: warn when the declared replica count exceeds the licensed node cap (#8504)
-
-The 2026-08-13 `max_nodes` ruling requires a licensed overflow to refuse the excess,
-run up to the paid limit, and **warn loudly**. The gate learned to express the first
-two — `admitted` / `refused` / `capped` — but the only program that consults it, `os
-serve`, called it zero-arg and typed the result with a hand-written
-`{ allowed, reason }` cast. So the partial-cap verdict was unreachable *and* unread:
-the gate could say "3 admitted, 2 refused" and nothing rendered it.
-
-`serve` now passes the operator-declared `OS_CLUSTER_REPLICAS` into the gate and
-emits an advisory on `capped`:
-
-```
-[cluster] licensed node cap exceeded: the licence admits 3 node(s), but
-OS_CLUSTER_REPLICAS declares 5 — 2 beyond the cap.
-[cluster] This cap is ADVISORY and is not enforced yet: nothing is refused, and all
-5 replicas will still join the cluster.
-[cluster] Reduce OS_CLUSTER_REPLICAS to 3, or raise the licensed node limit.
-```
-
-⚠️ The wording is deliberately advisory. Enforcement needs an atomic slot claim
-across replicas and is tracked separately; until it lands **nothing is actually
-refused** — every replica computes the same verdict at boot and none can tell whether
-it is one of the admitted ones, so all of them join. A message claiming "2 replicas
-refused" would be false in exactly the declared-vs-delivered way this warning exists
-to close.
-
-An outright `allowed: false` denial is untouched: it keeps reporting as a
-single-node downgrade, and is deliberately not reported as a cap.
diff --git a/.changeset/serve-registers-observability-service.md b/.changeset/serve-registers-observability-service.md
deleted file mode 100644
index 1b0bcc1a21..0000000000
--- a/.changeset/serve-registers-observability-service.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-"@objectstack/cli": patch
----
-
-`objectstack serve` now registers `ObservabilityServicePlugin`, so the `observability:metrics` service actually resolves for every consumer that follows the canonical resolution chain.
-
-`serve.ts` built one metrics registry from `OS_OBS_EXPORTER` and threaded it into a single consumer (the dispatcher). Nothing in the repo registered the service itself, so the cache and storage adapters walked the documented chain — explicit option, then `observability:metrics`, then a no-op — and held a `NoopMetricsRegistry` in every shipped deployment, however `OS_OBS_EXPORTER` was set.
-
-The registry is now built once and registered as a service ahead of the transport, the dispatcher and the capability providers, because every consumer resolves the chain during its own `init()`. Measured on a booted showcase app with `OS_OBS_EXPORTER=console`: `storage_operations_total` and `storage_operation_duration_ms` now emit where they previously emitted nothing, and the cache adapter now holds the configured registry instead of the no-op. `http_requests_total` is unchanged — it was already armed transport-wide by the dispatcher through the `IHttpServer.afterResponse` seam, and the per-server latch keeps it at exactly one observer.
-
-Deployments that leave `OS_OBS_EXPORTER` unset or set to `noop` are unaffected: nothing is registered, and the transport still installs no per-request middleware.
diff --git a/.changeset/serve-unknown-hostname-guard-test-seam.md b/.changeset/serve-unknown-hostname-guard-test-seam.md
deleted file mode 100644
index 2fce551dfc..0000000000
--- a/.changeset/serve-unknown-hostname-guard-test-seam.md
+++ /dev/null
@@ -1,22 +0,0 @@
----
-"@objectstack/cli": patch
----
-
-test(cli): `os serve`'s unknown-hostname guard gets a test seam — the middleware, refusal included, is now reachable without booting a server (#9442)
-
-The `OS_ROOT_DOMAIN` guard was a plugin object literal built inside
-`Serve.run()`, closing over its locals and installing itself on a `http.server`
-service resolved from the plugin context. Nothing about it was exported or
-constructible, so every branch — the health/readiness bypass whose own comment
-says a 404 there "would kill the container", the reserved-subdomain and
-`/_console` redirect branches, the `/_admin` and `/.well-known` pass-throughs,
-the lazy env-registry read whose every failure mode falls through — had zero
-regression coverage.
-
-It is now `createUnknownHostnameGuardPlugin()`, exported from `serve.ts` the way
-its sibling helpers are, with `run()` calling it. Behaviour is unchanged: same
-branches in the same order, same bodies, and `OS_CLOUD_URL` is still read per
-request rather than captured at install time. What is new is a suite that mounts
-the real middleware on a real Hono app and pins BOTH directions — every bypass
-as an explicit pass-through, and the refusal by `error.code` **and** HTTP status
-together.
diff --git a/.changeset/service-job-class-jsdoc-recordruns.md b/.changeset/service-job-class-jsdoc-recordruns.md
deleted file mode 100644
index f7952fd611..0000000000
--- a/.changeset/service-job-class-jsdoc-recordruns.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-"@objectstack/service-job": patch
----
-
-fix(services): the `DbJobAdapter` class JSDoc stops promising a `sys_job_run` row that `recordRuns: false` never writes (#9631)
-
-`tsup` emits this comment into `packages/services/service-job/dist/index.d.ts`, so it is
-the class-level editor tooltip an npm consumer of `@objectstack/service-job` reads. Its
-third "persisted side effects" bullet said **every execution writes a `sys_job_run` row**;
-`wrap()` gates that insert on `recordRuns`, which defaults to `true` but writes nothing at
-all when set to `false`. The same emitted `index.d.ts` states the truthful field-level rule
-for `recordRuns` sixty lines above, so the published declaration disagreed with itself
-about one flag — a reader hovering either one got a different answer.
-
-No runtime behaviour changes. This is a patch because the entire deliverable is text inside
-a published package's `.d.ts`: with no version bump the corrected tooltip never reaches npm
-and the fix is unmet in the only channel it is about.
-
-The corrected bullet defers to `DbJobAdapterOptions.recordRuns` via `{@link}` rather than
-restating the rule, so the two cannot drift apart again, and it names the one row the flag
-does not govern — `replay()`'s synthetic `trigger: 'replay'` row, written either way. The
-fourth bullet gains the matching negative: the `sys_job` counters are bumped
-unconditionally, `recordRuns` gating only the per-attempt rows.
-
-Five cases now pin the flag in both directions. Nothing in this package referenced
-`recordRuns` before, so both corrected sentences were accurate but unenforced.
diff --git a/.changeset/service-job-replay-honours-recordruns.md b/.changeset/service-job-replay-honours-recordruns.md
deleted file mode 100644
index 329dbebc1e..0000000000
--- a/.changeset/service-job-replay-honours-recordruns.md
+++ /dev/null
@@ -1,34 +0,0 @@
----
-"@objectstack/service-job": patch
----
-
-fix(services): `DbJobAdapter.replay()` honours `recordRuns` — an operator who switched run history off stops accumulating replay rows (#9633)
-
-`recordRuns` is the on/off switch for `sys_job_run` history, and it had exactly
-two `startRun` call sites. The gate landed on one of them: `wrap()`'s
-per-attempt row was gated, `replay()`'s synthetic row was not. So a deployment
-that set `recordRuns: false` wrote nothing for any scheduled or triggered
-execution and **one complete row for every replay** — a table the operator
-believes is switched off, filling slowly and exclusively with `trigger: 'replay'`
-rows, the least representative sample of a job's history and one with no
-non-replay rows beside it for context.
-
-The carve-out was never designed. `replay()`'s synthetic row exists to force the
-`trigger: 'replay'` tag that `IJobService.trigger` cannot carry back; the flag
-simply arrived later and landed on one of the two writers. It is closed rather
-than documented: one flag, one meaning, no second de-facto rule at a call site.
-If an operator-initiated replay needs to be auditable when routine history is
-off, the principled home for that is `sys_audit_log` — which has its own opt-in,
-writer and retention — not an exception to a history switch.
-
-**Behaviour change, user-visible:** with `recordRuns: false`, `replay()` now
-writes no `sys_job_run` row. The handler still executes, and `sys_job`'s own
-`last_run_at` / `last_status` / `run_count` / `failure_count` counters still
-update — the flag has never gated those. With the default (`true`) nothing
-changes: the synthetic row is still written, still tagged `trigger: 'replay'`,
-and still carries the terminal status read off the inner execution.
-
-All three of `replay()`'s arms are gated, not just the insert — the terminal
-status arm, the success arm and the catch arm — so the flag cannot leave a
-dangling `running` half-row with no `completed_at`, which would be worse than
-either original behaviour.
diff --git a/.changeset/service-jsdoc-declared-equals-actual.md b/.changeset/service-jsdoc-declared-equals-actual.md
deleted file mode 100644
index ed69200947..0000000000
--- a/.changeset/service-jsdoc-declared-equals-actual.md
+++ /dev/null
@@ -1,52 +0,0 @@
----
-"@objectstack/service-cache": patch
-"@objectstack/service-job": patch
----
-
-fix(services): two published `.d.ts` JSDoc comments stop describing behaviour their code does not have — `MemoryCacheAdapter` eviction is FIFO, not LRU, and `recordRuns` is an on/off switch, not a retention cap (#9611)
-
-Both comments are emitted by `tsup` into each package's built `index.d.ts`, so they
-are **the editor tooltip an npm consumer sees** — the same "published documentation
-asserting behaviour the runtime does not have" class as #9517 and #9532, in a third
-channel that no gate reads. No runtime behaviour changes in either package; this is a
-patch because the corrected text only reaches consumers through a release.
-
-**1. `MemoryCacheAdapter` — "LRU-style eviction" was never LRU.**
-
-The class comment advertised `TTL-based expiry and LRU-style eviction`. The eviction
-path takes `this.store.keys().next().value` — the first key in `Map` insertion order —
-and `get()` returns `entry.value` without ever deleting and re-setting the key, so a
-read does not move an entry back. Nor does an overwrite: `Map.set` on a key already
-present keeps its original insertion slot. Eviction has therefore always been
-**oldest-inserted (FIFO)**, which is a materially different hit-rate profile from the
-one the tooltip promised anyone sizing a cache.
-
-The comment was corrected rather than the code, deliberately: `maxSize` defaults to `0`
-(unlimited), so the eviction path is off by default and nothing shipped is getting FIFO
-where it expected LRU, and there is no measured pull for LRU. Minting a real behaviour
-change to make a stale sentence true inverts the fix — the defect is that the
-documentation lies, not that the cache is wrong. (A real LRU already exists in the repo,
-`packages/metadata/src/utils/lru-cache.ts`, for the callers that need one.)
-
-Four tests now pin the corrected sentence so it stops being an unenforced claim. Each is
-written as a **discriminator against LRU**: it reads (or overwrites) the oldest entry
-before overflowing the cache and then asserts that entry was evicted anyway — a hot key
-dies on age, an untouched newer key survives. The pre-existing eviction tests could not
-tell the two policies apart, which is how the wrong comment sat green.
-
-**2. `DbJobAdapterOptions.recordRuns` — the comment described a different field.**
-
-`/** Soft cap on sys_job_run rows recorded per job (defaults to none — handled by
-retention jobs) */` made three claims and the code contradicts all three: the field is a
-`boolean`, not a count; it defaults to `true`, not "none" (`args.options?.recordRuns ??
-true`); and it gates whether a `sys_job_run` row is written at all, rather than being
-trimmed later by retention. The sentence reads as if it belongs to the numeric
-`JobRunRetention` knob that ADR-0057 retired — a copy-paste that outlived its source.
-
-The consequence the new wording keeps in sight: **a reader who sets `recordRuns: false`
-expecting "no cap" gets run history switched off.** The replacement states the real
-meaning (one row per attempt, inserted at start and updated on settle, default `true`)
-and both things that are *not* affected by the flag — the `sys_job` row's own
-`last_status` / `run_count` / `failure_count` counters, which `bumpJob` updates
-regardless, and `replay()`, which writes its synthetic `trigger: 'replay'` row without
-consulting the flag at all.
diff --git a/.changeset/service-readmes-document-real-exports.md b/.changeset/service-readmes-document-real-exports.md
deleted file mode 100644
index 99a43a7e71..0000000000
--- a/.changeset/service-readmes-document-real-exports.md
+++ /dev/null
@@ -1,76 +0,0 @@
----
-"@objectstack/service-analytics": patch
-"@objectstack/service-automation": patch
-"@objectstack/service-cache": patch
-"@objectstack/service-i18n": patch
-"@objectstack/service-job": patch
----
-
-docs: five published service READMEs stop documenting an API that does not exist (#9532)
-
-A version bump is the point, not a side effect: these five READMEs are in their
-packages' `files` arrays with `private` unset, so they are the pages npm renders —
-and a docs-only fix with no bump never reaches npm at all.
-
-Each of the five told a reader to an import of a `Service…` class from its own package
-and call a static `.configure({...})` on it. Neither has ever existed: no class in
-this repo exposes a static `configure`, and none of `ServiceAnalytics`,
-`ServiceAutomation`, `ServiceCache`, `ServiceI18n` or `ServiceJob` is exported by
-anything. A reader following any of them wrote code that could not compile. The real
-entry point in every case is a kernel plugin constructed with `new`:
-`AnalyticsServicePlugin`, `AutomationServicePlugin`, `CacheServicePlugin`,
-`I18nServicePlugin`, `JobServicePlugin`.
-
-⛔ A name swap alone would not have been enough, and the gate landed in #9546 is what
-proves it: substituting the genuine class while keeping `.configure(...)` turns the
-import finding into a call-site finding rather than into silence. Each README is
-rewritten against the package's built type surface, and each package's entry is
-deleted from `scripts/published-readme-exports.baseline.json` in the same change
-(the baseline is reconciled in both directions, so a stale entry fails too).
-
-What was removed as fabricated, beyond the entry point:
-
-- **service-analytics** — a nine-endpoint REST surface (`/analytics/count`, `/sum`,
- `/avg`, `/min`, `/max`, `/group-by`, `/time-series`, `/metrics`, `/metrics/:name`)
- of which none exists; the real surface is `POST /analytics/query`,
- `GET /analytics/meta`, `POST /analytics/sql` and `POST /analytics/dataset/query`.
- Also removed: `defineMetric`, `getMetric`, `compare`, `funnel`,
- `executeDashboard`, `invalidateCache`, and an `AnalyticsServiceConfig` block whose
- four keys (`defaultDriver`, `enableCaching`, `cacheTTL`, `maxMemoryResults`) are
- none of the real ones.
-- **service-automation** — `executeFlow`/`getFlow`/`listFlows`/`getFlowHistory`/
- `registerTrigger` as the contract (the real contract is `execute(flowName, context?)`
- plus `listFlows()` and a set of optional members), and a five-endpoint REST list that
- matches no mounted route. The flow-authoring half of that README was already accurate
- and is kept.
-- **service-cache** — `mget`/`mset`/`del`/`delPattern`/`namespace`/`ttl`/`expire`/
- `persist`/`incr`/`incrby`/`decr`/`getOrSet`/`invalidateTag`/`resetStats`, none of
- which exist; `ICacheService` has six members. `CacheStats.keys`/`hitRate` corrected to
- `keyCount` (there is no `hitRate`), and `set(key, value, { ttl })` corrected to the
- real positional `set(key, value, ttl?)` in seconds.
-- **service-i18n** — an `await i18n.t('ns:key')` dialect with namespaces, plural
- suffixes, `context`, `returnObjects`, `setLocale`/`getLocale`, `formatDate`/
- `formatNumber`/`formatRelative`, `addLocale`/`removeLocale`/`reload`, `getCoverage`/
- `getMissingKeys`, and a `{{lng}}/{{ns}}` file layout. The real `t()` is synchronous
- and takes the locale positionally — `t(key, locale, params?)` — over one
- `{locale}.json` file per locale. The `POST /i18n/translate` endpoint does not exist.
-- **service-job** — `scheduleInterval`/`scheduleOnce`/`getJob`/`stopJob`/`resumeJob`/
- `deleteJob`/`runNow`/`getJobHistory`/`clearHistory`/`getLastExecution`, and a
- `schedule({ name, schedule, handler })` options-object call. The real `schedule` is
- positional — `schedule(name, schedule, handler, options?)` — and returns `void`.
- Retry defaults corrected to the enforced ones (`maxRetries: 0`,
- `backoffMultiplier: 1`).
-
-Two capability claims are corrected rather than deleted, because the source is what
-decides:
-
-- **service-cache** advertised Redis as production support. `RedisCacheAdapter` throws
- `RedisCacheAdapter not yet implemented` from every method, and
- `new CacheServicePlugin({ adapter: 'redis' })` throws during `init` rather than
- falling back to memory. The README now says so at the top and points at registering
- a custom `ICacheService` under the slot instead.
-- **service-job**'s `adapter: 'interval'` stores cron registrations that never fire.
- That is now stated in the adapter table rather than left for a reader to discover.
-
-No compliance claim (SOC 2 / HIPAA / GDPR or similar) was found in any of the five —
-the shape that raised `plugin-audit`'s severity in #9517 is absent here.
diff --git a/.changeset/sharing-declared-field-binder-converge.md b/.changeset/sharing-declared-field-binder-converge.md
deleted file mode 100644
index d0129fc270..0000000000
--- a/.changeset/sharing-declared-field-binder-converge.md
+++ /dev/null
@@ -1,45 +0,0 @@
----
-"@objectstack/plugin-sharing": patch
----
-
-fix(sharing): `publicSharing.eligibility` binds declared fields through the canonical `materializeDeclaredFields` instead of a local copy (#8489)
-
-`share-link-service.ts` carried its own `bindDeclaredFields` — a hand-written
-mirror of `@objectstack/objectql`'s `materializeDeclaredFields`, named as a copy
-in its own doc comment. It is retired; `assertEligible` now imports the
-canonical helper from `@objectstack/objectql/core` (already a runtime dependency
-of this package), with a spread at the call site because the canonical
-materialises in place.
-
-**This changes eligibility verdicts on exactly one row shape**, and the change
-was accepted knowingly (maintainer ruling, 2026-08-16). The retired mirror bound
-a declared field by key PRESENCE (`!(name in record)`); the canonical binds by
-VALUE (`record[name] === undefined`). They agree on every other input class,
-including a missing or malformed `fields` map, where both return the record
-untouched. Where they differ is a declared field held as an own key whose value
-is `undefined` — a shape `InMemoryDriver` measurably produces (an explicit
-`undefined` on `create` survives to `find`) and `SqlDriver` structurally cannot
-(a SQL NULL arrives as `null`).
-
-On that shape only, with a declared `status`:
-
-| eligibility predicate | before | after |
-|:------------------------------|:-------------------------------|:-------------------------|
-| `record.status == null` | 422 `ELIGIBILITY_UNEVALUABLE` | **link is minted** |
-| `has(record.status)` | 422 `RECORD_NOT_ELIGIBLE` | **link is minted** |
-| `!has(record.status)` | **link was minted** | 422 `RECORD_NOT_ELIGIBLE` |
-| `record.status == 'published'`| 422 `ELIGIBILITY_UNEVALUABLE` | 422 `RECORD_NOT_ELIGIBLE` |
-
-The first two rows widen acceptance: the predicate is now *answered* rather than
-faulting on a key CEL reads as absent, and on this fail-closed gate a fault was a
-refusal. The third row is the one that mattered for the decision — it **closes an
-over-acceptance**. `has()` guards an UNDECLARED key and never an empty value once
-bindings are materialised, so `!has(record.)` is false; the
-mirror was minting share links there that every other server-side surface
-refuses. The fourth row keeps its direction and changes only its ADR-0112 `code`.
-
-The eligibility pin is rewritten to discriminate (#9085): its previous
-declared-field case passed with the binder fully ablated, because every seeded
-row carried the field it claimed was absent. The replacements use a declared
-field the stored row genuinely does not carry, and fail in opposite directions
-under ablation.
diff --git a/.changeset/sharing-read-merge-provenance-mark.md b/.changeset/sharing-read-merge-provenance-mark.md
deleted file mode 100644
index c2afebc70b..0000000000
--- a/.changeset/sharing-read-merge-provenance-mark.md
+++ /dev/null
@@ -1,55 +0,0 @@
----
-"@objectstack/plugin-sharing": patch
----
-
-fix(plugin-sharing): stamp the filter-subtree provenance mark at the read merge, so an author's own cross-field refusal stops being redacted on the sharing-composed path (#8430)
-
-`#8220` declared the filter-subtree provenance mark and set it at two read-scope
-merge boundaries — `plugin-security`'s CRUD injection and `service-analytics`'
-`withReadScope`. `plugin-sharing`'s read path is a **third**: on every read it
-AND-composes an OWD / record-share visibility filter into `ast.where`, and it
-stamped nothing.
-
-Two marks, and they are not the same job:
-
-- **the scopes it injects are marked `'policy'`** — the OWD/record-share read
- filter, the delegator's intersected filter (ADR-0090 D10) and the
- `sys_record_share` self-scope (ADR-0111 D5). **No behaviour change**: an
- unmarked subtree already withheld, so these refusals kept the `#7929`
- redaction before and keep it now. What changes is that the withhold becomes a
- *declared* verdict instead of an accident of the mark's absence — which
- matters because an unmarked node **inherits its ancestor's mark positionally**
- (`resolveFilterSubtreeProvenance`, innermost wins), so an unmarked policy arm
- nested inside a vouched subtree would read as the author's.
-- **the caller's own predicate is vouched `'author'`** immediately before the
- rewrite that would otherwise make it unrecognisable to every later boundary.
- This is the one user-visible change: an author's own `{ $field }` refusal on
- an object with active sharing again names its columns, its operator and its
- reason, instead of the redacted "operands withheld" text.
-
-**The vouch is an identity check, not a heuristic.** The mark is stamped only
-while `ast.where` is still, by object identity, the `where` the caller handed
-the engine. If a sibling middleware already composed into it, or the engine
-rewrote it resolving filter tokens, identity fails and **nothing** is vouched —
-the tree stays unmarked, and unmarked withholds. The arms of a pure
-`{ $and: [ … ] }` root are vouched too, because `composeAnd`'s flattening branch
-spreads that root's arms into a new object and would otherwise drop the vouch
-out of the tree with it (that shape is what the array authoring form lowers to,
-so it is the common case, not an edge one).
-
-**Fail-closed is unchanged in every direction**, and the pins say so at a real
-`SqlDriver`: the injected scope still withholds, a policy arm sitting beside an
-author-vouched arm in the same `$and` still withholds, and a predicate no
-boundary ever vouched still withholds byte-identically to the policy case.
-
-**The write path is untouched.** `buildWriteFilter`'s composition is a different
-question with different consequences and was not declared by `#8220`.
-
-Measured while implementing, and worth recording because the card says
-otherwise: in a stack that composes **both** plugins, the author vouch was
-already surviving. `plugin-security` is registered before `plugin-sharing` on
-both real boot paths and `resolvePluginOrder` preserves insertion order, so
-security vouches first and its mark — which lives on the caller's object —
-travels through this composition untouched. The gap this fixes is a stack that
-mounts `plugin-sharing` **without** `plugin-security`, where nothing else can
-vouch for the caller.
diff --git a/.changeset/sharing-rule-inert-anchor-gate.md b/.changeset/sharing-rule-inert-anchor-gate.md
deleted file mode 100644
index d0225a1c4d..0000000000
--- a/.changeset/sharing-rule-inert-anchor-gate.md
+++ /dev/null
@@ -1,56 +0,0 @@
----
-"@objectstack/lint": minor
----
-
-feat(lint): a sharing rule anchored where sharing has nothing to widen is now an authoring-time error (#9698)
-
-`validateSharingRuleEnforceability` gains its second arm. It already judged a
-sharing rule's `condition` against the compiler that lowers it; it now judges
-the rule's `object` against the verdict that decides whether the grant can
-exist at all.
-
-Two new `error` ids, both decidable from authored metadata before anything
-boots, and both mirroring `SharingService.inertGrantReason` (ADR-0111 D7)
-rather than modelling it:
-
-- **`sharing-rule-object-not-shareable`** — the anchor object's effective
- sharing model is `public` (an explicit `sharingModel: 'public_read_write'`,
- or no `sharingModel` on a system object, which ADR-0090 D1 resolves to
- public). Sharing only ever WIDENS an OWD baseline, so on the widest baseline
- there is nothing to widen.
-- **`sharing-rule-object-controlled-by-parent`** — the anchor is a
- master-detail detail, whose visibility is derived from its master
- (ADR-0055). It gets its own id and its own fix-it ("share the master
- record instead"), because `effectiveSharingModel` collapses it onto the same
- `public` verdict while the correct repair is completely different.
-
-Both were previously accepted by `SharingRuleSchema`, accepted by `defineRule`,
-seeded into `sys_sharing_rule`, and only then refused — once per boot, as a
-WARN line inside the boot diagnostics block. That WARN is not a sufficient
-diagnostic, and the reason is measured rather than argued: a rule whose criteria
-match no seeded row never reaches `grant`, so it never throws and warns nothing
-while being exactly as dead. The WARN is a function of the DATA; the defect is a
-property of the DECLARATION.
-
-**Blast radius, measured through `objectstack build` before deciding the
-severity:** 5 sharing rules are declared in this repo. 3 fire, all of them in
-`examples/app-crm` — `share_high_value_opps_with_managers`,
-`share_active_leads_with_manager` and `share_won_deal_activities`, anchored on
-`crm_opportunity`, `crm_lead` and `crm_activity`, every one of them
-`sharingModel: 'public_read_write'`. They have been failing their boot backfill
-on every boot of that app since they were written, and they are removed here
-under ADR-0049 enforce-or-remove — the same call #9237 made for the two
-equivalent rules in `app-showcase`. The other 2 (app-showcase's, both on
-`private` objects) stay silent, which is the direction that had to be proven
-rather than hoped for.
-
-The CRM's smoke test used to assert that these rules existed and were of the
-enforced `criteria` type. Both assertions passed while all three rules enforced
-nothing, so the assertion is replaced by the property their greenness hid: no
-declared rule may be anchored where sharing has nothing to widen.
-
-Deliberately NOT judged, because they are not decidable from authored metadata:
-the `owner_id` arm (`owner_id` is injected by the schema registry, so asserting
-it would fail every object that correctly does not declare it by hand), the
-`bypassObjects` arm (plugin configuration, not stack metadata), and the
-federated phantom-anchor arm (a provenance test over that same injected column).
diff --git a/.changeset/showcase-checklist-seed-fixtures.md b/.changeset/showcase-checklist-seed-fixtures.md
deleted file mode 100644
index d84b3f066d..0000000000
--- a/.changeset/showcase-checklist-seed-fixtures.md
+++ /dev/null
@@ -1,49 +0,0 @@
----
-"@objectstack/example-showcase": patch
----
-
-Land the showcase seed fixtures the platform checklist could not run without (#9308)
-
-Three capabilities the platform ships had no fixture anywhere in the reference app, so the
-checklist items covering them were not failing — they were unrunnable. Each is closed here
-with the smallest stock addition that makes it observable, and with the negative control
-left intact.
-
-**A second, actually loginable member.** The demo personas (Mei Phone the submitter, Ada
-Auditor the sole `auditor`) have existed as `sys_user` rows since #3409/#3411, and neither
-could sign in — so every item needing two acting identities was stuck: per-group 会签 needs
-the two groups decided by two different people, submitter-side viewer gating needs the
-submitter looking at their own request, and an out-of-office delegation is only falsifiable
-when the delegate holds a separate token. The non-obvious half is why a password hash was
-never enough: better-auth 1.7 keys accounts on `(issuer, providerAccountId)`, so a
-credential row carrying any other issuer is invisible to sign-in, which then fails
-`INVALID_EMAIL_OR_PASSWORD` behind a "User not found" warn pointing at the user row rather
-than at the account. `seed-approval-demo.ts` now provisions the credential account through
-better-auth's own `$context` — its hasher, its `internalAdapter.createAccount` — and READS
-the issuer off the dev admin's own credential row instead of re-spelling a constant
-`plugin-auth` owns, so the two cannot drift. Dev-only by construction: the bootstrap runs
-only where the dev admin exists, and that admin is hard-gated on `NODE_ENV=development`.
-
-**An object that opts into `publicSharing`.** No stock object declared it, so
-`POST /share-links` answered 422 `SHARING_NOT_ENABLED` for every showcase object and the
-whole downstream half of link sharing — resolve, redaction, the audience and password
-gates, fail-closed revoke — was unreachable. `showcase_client_brief` opts in with
-`redactFields`, an expiry cap and an `eligibility` predicate, and the seed carries both a
-`published` brief (mint-eligible) and a `draft` one (refused `RECORD_NOT_ELIGIBLE`) so the
-predicate is falsifiable and not merely satisfied. Every other object still declines the
-opt-in, which is what keeps the per-object 422 a real control.
-
-**A `readable: false` FLS grant.** The app governed the three `showcase_project` budget
-figures with `readable: true, editable: false` — the WRITE half of field-level security —
-and authored no read-withheld grant at all, leaving `plugin-security`'s field masker with
-no stock fixture. `showcase_client_liaison` is that grant, on the same three fields, so the
-two sets read side by side as the two halves of one mechanism. All three figures move
-together because `budget_remaining` is a formula over `budget - spent` and masking one
-leaks it back through arithmetic.
-
-Downstream reconciliations, each deliberate: `access-matrix.json` gains two rows and moves
-none; the persona × CRUD sweep's census follows the matrix (50/50 → 54/54, arithmetic
-recorded at the assertion) and its fixture maps learn the new object; the position count
-pin follows the new position. The five checklist items whose `knownGaps` this closes are
-revised in the same change — gap text kept, marked closed-by-fixture, `revision` bumped,
-`history` appended.
diff --git a/.changeset/showcase-predicate-sparse-face-remainder.md b/.changeset/showcase-predicate-sparse-face-remainder.md
deleted file mode 100644
index ba3ca76d37..0000000000
--- a/.changeset/showcase-predicate-sparse-face-remainder.md
+++ /dev/null
@@ -1,25 +0,0 @@
----
-"@objectstack/example-showcase": patch
----
-
-Guard the showcase's authored action predicates against the sparse action face (#8990)
-
-Every record-scoped `visible` / `disabled` predicate in `app-showcase` now carries the
-`has()` guard the sparse action face requires, closing the remainder of #8990 in this
-repo. A row action's predicate binds a LIST ROW carrying only the view's `$select`
-projection, and CEL aborts with `No such key` on a column that row never projected —
-fail-closed, so the button silently is not offered.
-
-Measured against the running app's own payloads: 40 of the 53 predicates in
-`predicate-matrix.action.ts` aborted on a default-list row before this change and 0 do
-after, while every verdict on a record-detail binding is unchanged — the Full-vs-Minimal
-contrast the fixture exists to demonstrate is preserved exactly.
-
-The guard is minimal per predicate rather than blanket: `has()` alone where the read is
-only compared by `==` / `!=` (CEL compares heterogeneously and answers `false` rather
-than faulting), the full `has(x) && x != null` conjunction only where an operand can
-fault — traversal, method call, ordering, arithmetic, `in`, or a bare `!`.
-
-The teaching surfaces move with the code, since they quote it: `content/docs/ui/actions.mdx`
-(whose `visible: '!record.done'` was the exact negation shape that faults on a NULL
-column), `quick-start.mdx` and `build-with-claude-code.mdx`.
diff --git a/.changeset/showcase-sharing-rules-enforce-or-remove.md b/.changeset/showcase-sharing-rules-enforce-or-remove.md
deleted file mode 100644
index 41bf92d64c..0000000000
--- a/.changeset/showcase-sharing-rules-enforce-or-remove.md
+++ /dev/null
@@ -1,31 +0,0 @@
----
-"@objectstack/example-showcase": patch
----
-
-Retire the showcase sharing rules no gate could consult; re-home the position/compound demo (#9237)
-
-Booting `examples/app-showcase` logged two WARNs per boot — `SharingServicePlugin: boot
-rule backfill failed for rule` for `share_open_tasks_with_manager` and
-`share_red_projects_with_execs`. Both sat on objects declaring
-`sharingModel: 'public_read_write'`, where sharing has nothing left to widen, so
-`assertNotInertGrant` (ADR-0111 D7) refused every grant they reconciled. A third rule,
-`share_high_value_red_projects_with_managers`, was in exactly the same state and produced
-no diagnostic at all: its compound condition matched no seeded row, so `reconcile` never
-reached `grant` and never threw.
-
-`showcase_project` and `showcase_task` are `public_read_write` by deliberate ADR-0090 D1
-declaration and that OWD is load-bearing beyond the security demo, so no rule can ever take
-effect there. ADR-0049 enforce-or-remove leaves one honest move, and all three are removed
-rather than re-homed onto another public object — the shape the previous repair took, which
-moved the inertness instead of removing it.
-
-The two capabilities they carried are kept: a `position` recipient and a compound CEL
-condition (ADR-0058 D3) now live on `share_key_account_qualified_contacts_with_managers`,
-targeting `showcase_contact` (OWD `private`, and the `showcase_manager` set grants it
-`allowRead` — the object-level bit a share row still needs). The seeded contacts
-demonstrate the AND in both directions: rows satisfying either clause alone are not
-shared.
-
-`inert-wirings.test.ts` gains the guard that fails the build on the next such declaration,
-in both of its shapes — a rule anchored where the OWD leaves nothing to widen, and a rule
-whose audience holds no `allowRead` on the object it shares.
diff --git a/.changeset/silly-pandas-repeat.md b/.changeset/silly-pandas-repeat.md
deleted file mode 100644
index f5b3ffba8c..0000000000
--- a/.changeset/silly-pandas-repeat.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@objectstack/lint': minor
----
-
-Three write-surface lint rules now ask provenance, not just membership, before exempting a system column (#8663).
-
-`validate-hook-body-writes`, `validate-action-body-writes` and `validate-flow-node-writes` share one `IMPLICIT_FIELDS` set, which is object-INDEPENDENT: it answers "could this name be implicitly writable somewhere", never "did the platform provision a column for it on THIS object". On an ADR-0015 `external` object those diverge — the registry injects `owner_id` / `organization_id` / the audit family onto a federated object exactly as onto a local one, but the remote database owns the schema and no column exists behind them.
-
-Each rule now emits a new advisory finding on that path instead of staying silent — `hook-body-write-unprovisioned-anchor`, `action-body-write-unprovisioned-anchor`, `flow-node-write-unprovisioned-anchor` — sharing the `unprovisionedAnchorCause` / `unprovisionedAnchorHint` wording the read-axis rules already use. All three are `warning`: the flow-node rule's existence finding still gates at `error`, and its provenance finding deliberately does not, because the claim is about a remote schema this repo cannot see.
-
-An author-DECLARED column of the same name is untouched — on a federated object it maps a remote column the author vouches for. `FlowNodeWriteSeverity` widens from `'error'` to `'error' | 'warning'` accordingly.
diff --git a/.changeset/sort-axis-authoring-gate.md b/.changeset/sort-axis-authoring-gate.md
deleted file mode 100644
index 6e89134c26..0000000000
--- a/.changeset/sort-axis-authoring-gate.md
+++ /dev/null
@@ -1,86 +0,0 @@
----
-"@objectstack/lint": minor
----
-
-feat(lint): refuse a list-view `sort` that names a formula field, or no field at all, at authoring time (#9257)
-
-
-
-**BREAKING** accept-set narrowing on a published authoring surface, shipped as
-`minor` under the same lockstep launch-window convention the sibling
-`filter-preset-comparand` refusal used. Measured against the shipped corpus
-before landing at `error`: **56 reachable `sort` declarations across
-`examples/app-showcase`, `examples/app-crm`, `examples/app-todo` and
-`packages/platform-objects`, 0 violations** — so this narrows the accept set
-without failing any metadata that ships today.
-
-The SORT axis had a runtime refusal on both doors and no authoring gate. This
-adds the missing half, which is the exact shape #6674 closed for the SEARCH
-axis one axis over.
-
-**What was broken.** `ListViewSchema.sort` is
-`z.union([z.string(), Array<{ field, order }>])`, so the field name is a bare
-string and Zod validates only the shape. A list view authored with
-`sort: 'expected_revenue desc'` — a `formula` field — validated, published, and
-reported valid, then answered `400 INVALID_SORT` on **first load and every
-load**: the declared sort is the view's initial fetch, not an optional
-interaction, so the whole view fails with a status the author cannot connect to
-the declaration. Both runtime doors already refuse it — `assertSortFieldsExist`
-(`@objectstack/metadata-protocol`, #6994) at the REST ingress and
-`assertOrderByIsMaterializable` (`@objectstack/objectql`, #7095) on the engine's
-own boundary — and neither can reach the author.
-
-**What is refused**, at `error`, on every list-view sort a stack declares
-(`objects[].listViews.*.sort`, `views[].list.sort`, `views[].listViews.*.sort`):
-
-- `sort-field-unknown` — the name resolves to no field on the bound object.
- Judged on the head segment, matching the ingress gate's own rule so the two
- doors cannot disagree about which names are unknown.
-- `sort-field-unsortable` — the name is a real field whose type is **virtual**:
- computed on read, no stored column, nothing for any driver to `ORDER BY`. An
- unrefused sort on one returns `asc` and `desc` in byte-identical order.
-
-**What stays accepted, and this is the load-bearing half:** `summary` and
-`autonumber` sorts. Virtuality is judged by `isVirtualSearchField` /
-`SEARCH_VIRTUAL_TYPES` (`@objectstack/spec/data`), pinned to `formula` alone —
-the same spec storage fact the search ingress gate, the engine's search
-resolution and the FILTER axis' dotted-head classifier already read. It is
-deliberately **not** the spec's `COMPUTED_VALUE_TYPES`: that set is the WRITE
-contract ("never client-written") and gating a sort with it would refuse the two
-types that sort correctly — `summary` is a `table.float` the engine maintains,
-`autonumber` a `table.string` the engine assigns. Both directions are pinned by
-test, and the predicate boundary itself is pinned alongside them so the two
-"must not flag" cases cannot quietly stop meaning anything.
-
-Registry-injected system columns (`created_at`, `owner_id`, …) are skipped:
-they are real at runtime, never appear in authored `fields`, and `created_at` is
-the single most common ordering in the platform's own list views.
-
-## FROM → TO
-
-```ts
-// before — parsed green, published, then 400 INVALID_SORT on every load
-listViews: {
- forecast: { type: 'grid', sort: [{ field: 'expected_revenue', order: 'desc' }] },
-}
-
-// after — refused at authoring time, naming the field, the position and the fix
-listViews: {
- // denormalise the computed value onto a stored column and sort by that
- forecast: { type: 'grid', sort: [{ field: 'expected_revenue_stored', order: 'desc' }] },
-}
-```
-
-The rule joins `REFERENCE_INTEGRITY_RULES`, so it runs on `os validate`,
-`os lint` and `os compile` at once rather than being wired per command.
diff --git a/.changeset/spec-prompts-real-exports-9545.md b/.changeset/spec-prompts-real-exports-9545.md
deleted file mode 100644
index 7c8ff5df1a..0000000000
--- a/.changeset/spec-prompts-real-exports-9545.md
+++ /dev/null
@@ -1,32 +0,0 @@
----
-'@objectstack/spec': patch
----
-
-Published agent-authoring prompts now reference real exports.
-
-`prompts/create-new-project.md`, `prompts/implement-objectql.md` and
-`prompts/implement-objectos.md` told agents to import five symbols that
-`@objectstack/spec` does not export. Four failed loudly. The fifth did not:
-`import { Object } from '@objectstack/spec/data'` does not resolve, so the
-annotation in `export const AccountObject: Object = { ... }` bound to the
-**JavaScript global** `Object` instead — metadata authored from that prompt
-type-checked against a type that constrains nothing.
-
-- Object definitions now use the house authoring convention measured in the
- example apps, `ObjectSchema.create({ ... })`, which genuinely validates.
- Correcting it exposed that the prompt's own example set `enable.audit` /
- `enable.workflow`, neither of which exists; they are now `trackHistory` /
- `files`, the pair the schema's own docstring uses.
-- `implement-objectql.md` keeps the real `Field` and `QuerySchema` imports and
- derives the object metadata type as `z.infer`, matching
- both `prompts/instructions.md` ("interfaces must be inferred from Zod") and
- spec's own `src/contracts/schema-driver.ts`.
-- `ManifestSchema` becomes `ObjectStackDefinitionSchema` from the package root:
- the prompt's subject is `objectstack.config.ts`, which is neither of the
- `/system` manifests.
-- `IdentitySchema` / `PolicySchema` have no bare referent; Rule #2 now names
- `RLSUserContextSchema` and `RowLevelSecurityPolicySchema` from
- `@objectstack/spec/security`.
-- The three non-existent "Key Files to Watch" paths
- (`system/{manifest,identity,events}.zod.ts`) now point at `stack.zod.ts`,
- `security/rls.zod.ts` and `kernel/events.zod.ts`.
diff --git a/.changeset/spec-unresolvable-column-mysql-reach-addendum.md b/.changeset/spec-unresolvable-column-mysql-reach-addendum.md
deleted file mode 100644
index 97e110bc25..0000000000
--- a/.changeset/spec-unresolvable-column-mysql-reach-addendum.md
+++ /dev/null
@@ -1,34 +0,0 @@
----
-"@objectstack/spec": patch
----
-
-docs(spec): the `driver-sql-unresolvable-where-column-refused` ledger entry states MySQL's reach as it is after #8926, not as it was at registration (#9060)
-
-Text amendment to an already-registered ADR-0087 entry — the entry id, `surface`
-and `replacement` prescription are unchanged, and no accept/reject behaviour
-moves. What changes is the `reason`, which is upgrader-facing documentation: it
-is the data source for `objectstack migrate meta`, `spec-changes.json` and the
-generated upgrade guide.
-
-The entry's "Reach, stated rather than assumed" paragraph said MySQL was outside
-the refusal — true when #8790 registered it, false the moment #8926 merged (PR
-#9061). A MySQL user reading "on MySQL this condition still travels out as the
-raw dialect error" would have concluded the migration did not apply to them,
-which is exactly wrong after parity.
-
-The historical paragraph is kept verbatim as the state at registration, and a
-dated addendum states both halves of what the one shared predicate did on MySQL:
-
-- **The envelope** — an unresolvable WHERE column refuses with the same
- `INVALID_FILTER` / 400 naming the column, instead of the raw
- `ER_BAD_FIELD_ERROR` with the statement's bound literals inlined.
-- **The recoveries** — MySQL also gained the #3821 projection and ORDER-BY
- recoveries it never had, so those positions now return recovered rows where
- they used to throw.
-
-Both arrive together because `ER_BAD_FIELD_ERROR` spells every clause position
-with one sentence, so all three ride one arm of the predicate — pinned as the
-ruled direction by the widened sweep in
-`sql-driver-unresolvable-where-column-refusal.test.ts`. Unchanged by that
-ruling, and said so in the addendum: a dotted filter key is still classified per
-dialect, the axis #8371 owns.
diff --git a/.changeset/sso-provider-map-id-param-retired.md b/.changeset/sso-provider-map-id-param-retired.md
deleted file mode 100644
index 069d8dc6a6..0000000000
--- a/.changeset/sso-provider-map-id-param-retired.md
+++ /dev/null
@@ -1,58 +0,0 @@
----
-"@objectstack/platform-objects": patch
-"@objectstack/plugin-auth": patch
----
-
-fix(platform-objects): drop the dead `mapId` ("Map: User ID claim") param from `register_sso_provider` — the OIDC subject claim is not configurable (#8222)
-
-
-
-The `register_sso_provider` action on `sys_sso_provider` offered an optional
-**"Map: User ID claim"** text field (`mapId`), with helpText reading *"Optional.
-ID-token claim mapped to the user ID. Defaults to `sub`."*
-
-**That capability no longer exists.** It was retired upstream in
-`@better-auth/sso@1.7.0-rc.2`:
-
-- `oidcConfig.mapping` is a `z.strictObject` whose members are
- `{ email, emailVerified?, name, image?, extraFields? }` — there is no `id`;
-- the federated subject is hard-wired to the OIDC `sub` claim
- (`id: readStringClaim(rawUserInfo, "sub")` and `id: idToken.sub`), then
- cross-checked (`id_token_subject_missing`,
- `id_token_userinfo_subject_mismatch`);
-- `extraFields` is not an escape hatch — it is spread **before** `id` in the
- profile literal, so an `extraFields.id` is overwritten by `sub` before anything
- reads it.
-
-`1.6.20` did honour `mapping.id` (`id: rawUserInfo[mapping.id || "sub"]`); the
-version bump deleted the member.
-
-So the field's only accepted values were "empty" and the `sub` it already
-defaulted to. #8193 (PR #8221) stopped the bridge emitting the retired key and —
-rather than accept a value it would silently discard — made a non-`sub` value
-answer `INVALID_REQUEST`. That left the last half of the problem: **the form
-still advertised a free-form optional field that 400s on anything meaningful.**
-Removing it restores declared = enforced. Nothing else about registration moves:
-the runtime accept set is unchanged, and a registration that never sent `mapId`
-behaves exactly as before.
-
-`mapEmail` and `mapName` are untouched — they map to live `oidcMappingSchema`
-members and are still honoured.
-
-**The bridge-side guard in `plugin-auth`'s `register-sso-provider.ts` is kept**,
-and its refusal test with it. The admin form was only one caller: a direct API
-client, a script, or a stale cached console bundle can still put `mapId` on the
-wire, and telling those callers plainly still beats discarding the value in
-silence. Only the guard's doc comment changed, to stop describing `mapId` as a
-field the form sends.
-
-The generated translation bundles (`*.objects.generated.ts`, all four locales)
-were **regenerated**, not hand-edited, so the retired label disappears from every
-locale rather than lingering as a stale entry.
diff --git a/.changeset/stack-top-level-unknown-keys-refused.md b/.changeset/stack-top-level-unknown-keys-refused.md
deleted file mode 100644
index 573d016d57..0000000000
--- a/.changeset/stack-top-level-unknown-keys-refused.md
+++ /dev/null
@@ -1,62 +0,0 @@
----
-"@objectstack/spec": minor
----
-
-feat(spec): refuse unknown top-level stack keys — `ObjectStackDefinitionSchema` goes strict (#8687, the outermost #4001 door)
-
-**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 top-level stack definition was the last strip-mode authoring surface of
-the #4001 campaign: an unknown top-level key parsed green and its value was
-silently dropped. Measured on 17.0.0 GA (#8687): three injected bogus
-top-level keys added ZERO warnings to `os validate` and exited 0 — even
-`--strict` could not catch them, because the `defineStack:` naming diagnostic
-printed at load, outside the warning tally. The failure population is a typo
-or stale key (`flow` for `flows`, `approvalProcesses` after the 7.4 removal)
-shipping an artifact with a whole metadata family absent at runtime — the
-root of hotcrm#1141.
-
-**What is refused:** any top-level key the schema does not declare, with a
-prescriptive message naming the surface and the offending key. A near miss
-carries the did-you-mean the load-time lint used to print (`objectz` →
-`objects`, `flow` → `flows`) — the near-miss resolver survives, now riding
-the refusal itself, and `lintUnknownStackKeys` goes quiet on the strict
-surface by its own posture rule (one voice, not two). Curated prescriptions
-answer the known retirements: `storage` (deployment config, `OS_STORAGE_*`),
-`approvals`/`approvalProcesses` (Approval-node flows, ADR-0019), `workflows`
-(`state_machine` validation rules, ADR-0020), `portals` (removed, #3464),
-`onDisable` (never invoked, #4212).
-
-**What stays accepted:** every declared key byte-identically — and `onEnable`
-is now DECLARED rather than undeclared-but-honoured: `AppPlugin` has always
-executed it off the authored bundle (#4095 grafts it back on artifact boot),
-and a strict close of an undeclared `onEnable` would have refused the pattern
-our own examples ship. `declared = honoured`, in both directions.
-`composeStacks` treats `onEnable` as single-valued: same value passes, a
-disagreement is refused naming both stacks.
-
-A strict parse failure fails `os validate` outright — exit 1 with or without
-`--strict` — so the CI gap closes with no warning-accounting change.
-
-## FROM → TO
-
-```ts
-// before — parsed green; the whole flows family was silently absent at runtime
-export default defineStack({ manifest, objects: [...], flow: [myFlow] });
-
-// after — refused at parse: "Unrecognized key(s) on this stack definition:
-// `flow`. Did you mean `flow` → `flows`?"
-export default defineStack({ manifest, objects: [...], flows: [myFlow] });
-```
-
-There is deliberately no automatic rewrite: an undeclared top-level key
-either names a capability the declaration surface does not deliver (blessing
-it would be declared-but-unenforced surface, ADR-0078) or is a spelling of a
-declared one, which the rejection names. `os migrate meta` surfaces the
-change as a structured TODO (semantic entry
-`stack-top-level-unknown-keys-refused`, protocol major 18 — this refusal is
-not part of the v17.0.0 cut).
-
-
diff --git a/.changeset/storage-slot-canonical-rename.md b/.changeset/storage-slot-canonical-rename.md
deleted file mode 100644
index ee9d32d1b7..0000000000
--- a/.changeset/storage-slot-canonical-rename.md
+++ /dev/null
@@ -1,40 +0,0 @@
----
-"@objectstack/spec": minor
-"@objectstack/service-storage": patch
-"@objectstack/runtime": patch
-"@objectstack/metadata-protocol": patch
-"@objectstack/cli": patch
-"@objectstack/plugin-email": patch
-"@objectstack/plugin-dev": patch
----
-
-feat(spec): `storage` becomes the canonical `CoreServiceName` slot; `file-storage` stays a deprecated v17 alias (#9683)
-
-
-
-Maintainer ruling, 2026-08-18, verbatim: 「9683 file-storage 可以叫 storage」.
-The `file-storage` slot was the only `CoreServiceName` member whose spelling
-diverged from its documented accessor (`services.storage`), with no recorded
-reason anywhere in the tree.
-
-- `CoreServiceName` gains `storage` as the canonical member; `file-storage`
- stays an accepted, deprecated alias within v17 (it is a published enum
- member — existing `getService('file-storage')` callers keep working).
- `CORE_SERVICE_PROVIDER` and `ServiceRequirementDef` carry both.
-- `@objectstack/service-storage` registers the **same instance** under both
- names (the `http.server` / `http-server` pattern), pinned by an
- alias-equivalence test.
-- Every internal consumer resolves `storage`: the HTTP dispatcher, the email
- plugin's attachment store, and `os migrate files-to-references`. Discovery
- reports the service under the canonical `storage` key and mirrors the row
- verbatim under the `file-storage` key for the alias's v17 lifetime, so
- existing discovery readers (e.g. the console endpoint catalog) keep
- working.
-- Docs (`kernel/runtime-services`, `kernel/contracts`) now document the
- canonical slot; a custom v17 provider for this slot should register both
- names.
diff --git a/.changeset/summary-index-registry-read-propagates.md b/.changeset/summary-index-registry-read-propagates.md
deleted file mode 100644
index f1df889463..0000000000
--- a/.changeset/summary-index-registry-read-propagates.md
+++ /dev/null
@@ -1,48 +0,0 @@
----
-"@objectstack/objectql": patch
----
-
-fix(objectql): the roll-up summary index's registry read propagates, and a failed read is never cached as an empty index (#9154)
-
-`ObjectQL.buildSummaryIndex()` opened with
-
-```ts
-try { objects = (this._registry as any).getAllObjects?.() ?? []; } catch { objects = []; }
-```
-
-and `ensureSummaryIndexes()` MEMOIZES what that build returns, stamped with the
-registry's current `objectRevision`. So a read that could not run was answered
-with an invented *"no object declares a roll-up"* — and then remembered as if it
-had been measured. `recomputeSummaries()` consults that index after every insert,
-update and delete to decide which parent roll-ups a child write must recompute,
-so an empty index means no roll-up is ever recomputed: every parent summary field
-keeps a stale value, nothing is logged, and every write reports success.
-
-Two changes, because the cache is the half that made this worse than the
-identical seams fixed in #9002:
-
-- **The read propagates.** Same family as #8895 (*discriminate or propagate*) and
- #9002, same reasoning: discrimination needs a benign failure class and there is
- none — an unreadable registry is never truthfully "no roll-ups". Both halves of
- the swallow are gone, the `catch` and the optional call `?.()`, which absorbed a
- registry that does not implement `getAllObjects` at all — the structural
- omission that never throws and is therefore invisible.
-- **A failed build leaves no cache entry.** `objectRevision` moves only on a
- metadata mutation (`registerObject`, `unregisterObject`,
- `unregisterObjectsByPackage`, `removeObjectOverlay`, `invalidate`,
- `invalidateAll`, `reset`) and never on a data write, so the invented emptiness
- outlived the condition that caused it — a steady-state deployment performs none
- of those, leaving every parent roll-up frozen until a restart or an unrelated
- publish. The build now runs to completion into a local before anything is
- published to the instance, the revision stamp is written last, and a throw
- clears any cached index and resets the stamp before rethrowing unchanged: the
- next call rebuilds. *A poisoned cache entry must not survive the read that
- poisoned it.*
-
-**No shipped behaviour changes.** `SchemaRegistry.getAllObjects()` is a walk over
-in-memory `Map`s calling `resolveObject()` — which returns `undefined` on every
-failure branch it models — over a fold that is spreads and comparisons. No I/O,
-no driver, no `throw` on the measured path, re-derived on today's tree. 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 silently freezing
-every roll-up in the deployment.
diff --git a/.changeset/sys-comment-moderation-delete-policy.md b/.changeset/sys-comment-moderation-delete-policy.md
deleted file mode 100644
index 6531a694db..0000000000
--- a/.changeset/sys-comment-moderation-delete-policy.md
+++ /dev/null
@@ -1,78 +0,0 @@
----
-"@objectstack/plugin-security": patch
----
-
-fix(security): comment moderation stops being dead behind the platform delete floor — `sys_comment` gets the per-object delete policy that lets a parent-record editor moderate (#8839)
-
-
-
-`plugin-audit` implements an explicit **author-or-parent-editor** rule for
-removing a comment — *"Rewriting or removing someone else's words is moderation,
-hence the tighter author-or-parent-editor rule"* — deriving a comment's access
-from the record its `thread_id` names, the way an attachment's derives from its
-parent.
-
-**That rule was unreachable in every org-bound deployment.** `member_default`
-ships a wildcard row-level delete floor:
-
-```
-{ name: 'owner_only_deletes', object: '*', operation: 'delete',
- using: 'created_by == current_user.id', positions: ['org_member'] }
-```
-
-A parent-record editor moderating someone else's comment holds `org_member` and
-is not the comment's `created_by`, so the floor answered `PERMISSION_DENIED`
-before the moderation rule was ever consulted. The floor is a **second,
-parent-blind implementation** of "who may remove this row", and on `sys_comment`
-it was winning against the one authority that can actually see the parent.
-
-**Why nothing caught it:** the only fixture proving the capability
-(`comments-permission-matrix.dogfood.test.ts` case (d)) booted **org-less**, so
-its principals resolved `positions: ['everyone']`, the positions-gated floor never
-applied, and the case passed over the broken behaviour — #8023's disarm shape.
-
-**The fix is one per-object policy** in `member_default`:
-
-```
-{ name: 'sys_comment_moderation', object: 'sys_comment', operation: 'delete',
- using: 'id != null', positions: ['org_member'] }
-```
-
-It contributes the **alternate match** that stops the floor pre-empting the gate;
-it does not re-implement the rule. The parent-editor limb is not expressible as a
-row predicate — the authority lives on another record and RLS has no join — so
-`id != null` is every row of this object said plainly, the same spelling and
-reasoning as the existing `sys_invitation_org_admin`. What actually narrows a
-`sys_comment` delete is, in order: the object-level delete bit (this set grants no
-`allowDelete` at all), Layer 0's tenant wall, and then plugin-audit's gate, which
-requires every matched row to pass and fails closed on a thread naming no
-authorizable parent. That gate is not optional — `AuditPlugin` registers
-`sys_comment` and installs the gate in the same `start()`.
-
-⛔ **The wildcard floor itself is unchanged.** The widening is scoped to
-`sys_comment`, and to the `delete` limb only; the `update` half of plugin-audit's
-rule deliberately stays under the floor.
-
-The `positions: ['org_member']` domain is load-bearing rather than cosmetic: it
-confines the widening to exactly the principals the floor binds. An undomained
-twin would carry a `using` into a delete class that is **empty** today for
-org-less and `everyone`-only principals, switching off the derive-from-select rule
-that currently bounds their writes to their readable set — widening them too.
-
-Access-widening approved by maintainer ruling (2026-08-15), which is what the
-standing manual floor on relaxing an access-control boundary required.
-
-The pin is the fixture, now **armed**: `orgContext: true` plus `assertArmed` on
-both the author and the moderator persona, so the file can never again certify
-moderation from a boot structurally unable to observe the floor. Reverse-verified
-— with the policy removed and the artifact rebuilt, exactly one case reddens with
-`PERMISSION_DENIED` on `sys_comment` and the other nine stay green. The
-stranger-without-parent-EDIT case now asserts its refusal code **exactly**
-(`RECORD_NOT_ACCESSIBLE`, plugin-audit's gate — not the floor's
-`PERMISSION_DENIED`), so the floor silently re-asserting itself over `sys_comment`
-cannot pass as a correct refusal.
diff --git a/.changeset/sys-email-headers-internal.md b/.changeset/sys-email-headers-internal.md
deleted file mode 100644
index eafab8ec83..0000000000
--- a/.changeset/sys-email-headers-internal.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@objectstack/platform-objects': minor
-'@objectstack/plugin-email': patch
----
-
-Stop serving custom email headers through the generic data-API read of `sys_email` (#8149).
-
-**What this closes.** `sys_email.headers_json` — the custom headers handed to `IEmailService.send`, the ordinary place a relay credential or provider token goes — was readable by every caller the data API admits (list, get, an explicit `?select=headers_json`). The column is now declared `internal: true`, so the engine omits it from every generic read with no system carve-out (#7728); `SYSTEM_CTX` does not reopen it either. This is the same shape #8118 ruled on for `sys_http_delivery.headers_json`: this change adopts that remedy rather than deciding it a second time.
-
-**Delivery is unaffected, and fail-closed.** `sys_email` is not delivered from the in-memory message but FROM THE ROW: the after-insert outbox drain hook, the `email.send.async` queue subscriber and the boot outbox sweep all re-read the row and hand it to `EmailService.deliverPersistedRow`. All three read through `engine.find`, which is exactly what the flag empties — so the recovery ships with the flag. `deliverPersistedRow` now recovers the column through ObjectQL's privileged accessor (`resolveInternalField`, consumed unchanged) and sends every authored header verbatim. A message whose headers cannot be recovered is NOT sent without them: a missing header is not self-announcing — a relay that does not require it accepts the mail while the delivery silently deviates from the authored configuration. That case throws and leaves the row `queued`, not `failed`, so the queue retry or the next boot's sweep delivers it intact.
-
-**New optional seam.** `EmailPersistence.readHeadersJson(rowIds)` — the readback the plugin wires off the raw engine. It probes the OBJECT SCHEMA flag, never the absence of the key from a result row: `headers_json` is `required: false` and most real rows carry no custom headers at all, so a key-absence inference would treat every ordinary email as redacted (the regression measured on `sys_account`'s optional token columns in #7987/PR #8675). Engines that do not redact are left untouched and trigger no privileged read.
-
-**What this deliberately does NOT close.** The row still holds the header map in cleartext at rest. Encrypting it (`Field.secret()`) was measured and rejected on #8118 — an orphan `sys_secret` row per message with no cascade or retention, a boot-window fail-open, and a per-row decrypt on every delivery — and this change adopts that ruling unchanged.
diff --git a/.changeset/sys-job-global-unique-scope.md b/.changeset/sys-job-global-unique-scope.md
deleted file mode 100644
index 9135f097da..0000000000
--- a/.changeset/sys-job-global-unique-scope.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-"@objectstack/platform-objects": patch
----
-
-State `sys_job`'s uniqueness boundary explicitly: `unique: 'global'` on the declared `(name)` index, and correct the `name` field's description (#8578)
-
-The declared index carried the bare `unique: true` spelling, which ADR-0120 D1 defines as the deprecated positional spelling of `'global'` — the listed columns verbatim. Because `sys_job` also carries a kernel-injected `organization_id`, the tenancy sweep could not tell that shape apart from the #8323 cross-tenant-oracle class, and the field's description published a boundary-free "Unique job identifier" claim that left the question open in the generated reference.
-
-The reading settles it in the `'global'` direction: nothing writes `sys_job` per organization. `DbJobAdapter` is the sole writer and upserts under a SYSTEM context, locating rows by `where: { name }` with no organization dimension; the `job` metadata type is closed to tenants on all three flags (`allowOrgOverride: false` — "no per-org job fork" — plus `allowRuntimeCreate: false` and `supportsOverlay: false`); `enable.apiMethods` advertises no write verb at all (ADR-0103 engine-owned); and every `schedule()` call site is registration-time and installation-scoped. ADR-0120's own S5 inventory already names `sys_job.name` as one of the nine engine idempotency keys that are platform-wide by construction.
-
-No migration and no drift: `'global'` **is** the semantics bare `true` already materialized, so the physical index is byte-identical (ADR-0120 D2). What changes is that the boundary is stated rather than inferred from position, and that the published description names it. The reading itself is pinned — the new test asserts the write paths that would have to open for the opposite verdict to become true, so a future per-organization job path fails loudly instead of silently invalidating the constraint.
diff --git a/.changeset/sys-job-run-description-history.md b/.changeset/sys-job-run-description-history.md
deleted file mode 100644
index 318c50d142..0000000000
--- a/.changeset/sys-job-run-description-history.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-"@objectstack/platform-objects": patch
----
-
-Fix `sys_job_run`'s object `description` to say "history", not "audit trail" (#9735)
-
-`sys_job_run` is job run **history**; `sys_audit_log` is the separate audit surface,
-with its own opt-in, writer and retention (binding ruling on #9633). The object's own
-header comment already said "Background Job Execution History", but the `description`
-field two lines below — the user-facing copy Studio/Setup surface, and the string that
-propagates into the generated translation bundle — still called it "Background job
-execution audit trail". Both now say "Background job execution history"; the generated
-`en.objects.generated.ts` bundle was regenerated to match (never hand-edited).
diff --git a/.changeset/sys-position-bundle-locales-per-organization.md b/.changeset/sys-position-bundle-locales-per-organization.md
deleted file mode 100644
index 3f7a188ddf..0000000000
--- a/.changeset/sys-position-bundle-locales-per-organization.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-"@objectstack/plugin-security": patch
----
-
-Correct `sys_position`'s translated uniqueness text in the `es-ES`, `ja-JP` and `zh-CN` bundles to say the machine name is unique **per organization**
-
-The English bundle and the object source both already state that a position's machine name is unique per organization — the declared index is `{ fields: ['name'], unique: 'organization' }`. The three other shipped locales still asserted bare, unqualified uniqueness, so an admin reading Setup in Spanish, Japanese or Chinese was told the name had to be free installation-wide, which the declared index does not enforce.
-
-Both places `sys_position` states the rule are corrected:
-
-- `fields.name.help`, the field help in the object's detail and edit views. It now also carries the source's current examples (`sales_manager`, `hr_specialist` rather than the superseded `admin`, `editor`, `viewer`).
-- `actions.clone_position.params.name.helpText`, the help on the Clone Position dialog's API-name input — the text an admin reads at the moment they type a new name.
-
-Leaf string values only — no bundle structure was hand-edited.
diff --git a/.changeset/sys-setting-null-safe-row-identity.md b/.changeset/sys-setting-null-safe-row-identity.md
deleted file mode 100644
index 9fe0d86864..0000000000
--- a/.changeset/sys-setting-null-safe-row-identity.md
+++ /dev/null
@@ -1,71 +0,0 @@
----
-"@objectstack/metadata-protocol": patch
----
-
-fix(metadata-protocol): `sys_setting`'s declared row identity is enforced on the tenant and global layers — a runtime NULL-safe UNIQUE index over `COALESCE(user_id, '')` (#8629)
-
-
-
-`sys-setting.object.ts` declares the object's row identity as
-`{ fields: ['namespace', 'key', 'scope', 'user_id'], unique: 'organization' }`,
-and the object's own header calls that the row identity. It was not one.
-`user_id` is NULL on every row that is not `scope='user'` — `SettingsService.set`
-computes it as `scope === 'user' ? ctx.userId ?? null : null` — and SQL UNIQUE
-treats NULLs as mutually distinct, so the constraint was **void on the `tenant`
-and `global` limbs**: exactly the two carrying organization-level and
-platform-level configuration.
-
-Measured on a real engine, before this fix: two identical `scope='tenant'` rows
-in ONE organization both landed (`201`, `201`), two identical `scope='global'`
-platform defaults both landed, while the same rows with a non-NULL `user_id`
-were refused — the control that identifies the mechanism as the NULL rather than
-the `scope` value. `SettingsService` then resolves a layer with a positional
-`rows.find(...)` and `set()` upserts against `{ namespace, key, scope, user_id }`,
-so which value an organization got for a tenant-scoped key was unspecified and
-two rows could disagree indefinitely with no way for an admin to see why the
-effective value was not the one they set. `lifecycle.retention_overrides` is a
-live tenant-scoped key, so this reached real retention behaviour.
-
-The fix follows the paradigm that has shipped twice in this package
-(`ensureOverlayIndex`, `ensureViewDefinitionActiveIndex`): at `kernel:ready` the
-declared index is rebuilt in raw SQL with both nullable key parts folded —
-`COALESCE(organization_id, '__global__')` (ADR-0120 D3's tenant form, unchanged
-from what the driver already emits) and `COALESCE(user_id, '')` (the
-`ensureOverlayIndex` spelling for a non-tenant nullable discriminator). Storage
-is untouched: the row keeps its NULL, only the index folds it. The index reuses
-the **declared name**, so the additive `syncDeclaredIndexes` — which skips by
-name — never re-imposes the NULL-distinct form on a later boot, and the drift
-reconciler leaves it alone because an index carrying a non-tenant expression key
-part is not sync-reproducible.
-
-**⚠️ Operator-visible: this is a TIGHTENING, and on an installation that has
-already accumulated duplicate settings rows it will REFUSE to build the index.**
-That is the intended behaviour, not a failure mode to work around. Those
-duplicates exist precisely because the constraint has been void, and settings
-rows are admin-authored configuration, so no row is discarded automatically and
-no deterministic keep-one rule is applied. On refusal:
-
-- **nothing is deleted, rewritten or reordered**, and the boot continues;
-- the **previous index stays in place** — the tightening is proved buildable
- under a throwaway probe name before the declared name is ever dropped, so the
- table never spends a moment with no unique index at all;
-- one `error` line names the key that is not enforced, the consequence (duplicate
- tenant-scope and global-scope rows can still be created, and `SettingsService`
- has no defined answer for which one wins), and ships the **exact query that
- lists the offending rows**, so the operator has the list from the boot log
- without waiting for `os migrate plan`;
-- the migration keeps refusing on every boot until an operator decides which row
- survives, then converges on the next restart.
-
-Two hosts are deliberately quiet rather than degraded: a kernel composed without
-the optional `service-settings` has no `sys_setting` table at all, which is
-probed for and is a silent no-op; and a MySQL/MariaDB server that rejects
-functional key parts keeps the previous index and is told what is not enforced,
-the same degradation `SqlDriver.createNullSafeUniqueIndex` already reports for
-this class of event.
diff --git a/.changeset/sys-setting-probe-mysql-spelling.md b/.changeset/sys-setting-probe-mysql-spelling.md
deleted file mode 100644
index 1b7ba6c5db..0000000000
--- a/.changeset/sys-setting-probe-mysql-spelling.md
+++ /dev/null
@@ -1,33 +0,0 @@
----
-"@objectstack/metadata-protocol": patch
----
-
-fix(metadata-protocol): the sys_setting degradation report hands MySQL operators a duplicate-probe statement MySQL can actually run (#9434)
-
-When `sys_setting`'s NULL-safe row-identity index cannot be built, the migration
-degrades and prints a query so the operator can list the duplicate rows
-themselves. The `unsupported` arm is reached specifically on MySQL/MariaDB — no
-functional key parts, no `CREATE INDEX IF NOT EXISTS` — so that arm's audience is
-exactly one dialect, and the statement it printed used bare identifiers. `key` is
-a RESERVED word on MySQL, so the one remedy offered to a MySQL operator came back
-as `ERROR 1064 (42000)`, measured on a live MySQL 8.0.46. Nothing in the platform
-executes the statement, so no boot path was affected — what failed was the
-operator's copy-paste, in the arm that has no other remedy to offer.
-
-That arm now prints the MySQL spelling: every identifier quoted with backticks,
-the convention `seed-tenancy-backfill.ts` adopted for the same reason in #9381.
-Both spellings are generated from one body over one key-part array, so the
-operator's list and the index's own key cannot drift apart, and the ANSI
-statement `buildSysSettingDuplicateProbeSql()` returns is unchanged byte for byte
-— the `conflict` arm still prints it, because a conflict means real rows blocked
-a build the server was willing to attempt, which only SQLite and PostgreSQL ever
-are.
-
-Identifiers are quoted uniformly rather than only where a word looks reserved:
-MySQL's reserved-word list grows across point releases, and #9381's `last_value`
-is the recorded case of quoting the table while leaving a reserved column bare.
-
-The `CREATE UNIQUE INDEX` statement is deliberately untouched and still bare.
-MySQL refuses it for reasons quoting does not reach — unparenthesized `COALESCE`
-key parts — and that verdict is now checked on a live server rather than
-asserted, in both its quoted and unquoted spellings.
diff --git a/.changeset/sys-webhook-explicit-api-exposure.md b/.changeset/sys-webhook-explicit-api-exposure.md
deleted file mode 100644
index 2e1589a290..0000000000
--- a/.changeset/sys-webhook-explicit-api-exposure.md
+++ /dev/null
@@ -1,46 +0,0 @@
----
-"@objectstack/plugin-webhooks": patch
----
-
-chore(plugin-webhooks): `sys_webhook` declares its data-API exposure explicitly — recording the posture, not narrowing it (#9756)
-
-`sys_webhook` shipped with no `enable` block at all, so it kept the full default
-data API. Three cards each noticed and each named the narrowing as the next
-step — #7799 (the signing secret), #7986 (the custom headers), #8025 option 2
-(the URL) — and each assumed a later one would write the line. None did, and the
-last of them closed `completed` with the line still unwritten. The posture was
-never a judgement; it was a default nobody had written down.
-
-It is written down now:
-
-```ts
-enable: { apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'] }
-```
-
-**The effective surface is unchanged, and that is the honest headline.** The set
-is derived from a census of who actually reaches the object, taken before
-anything was edited:
-
-| consumer | reaches it through | needs |
-|:---|:---|:---|
-| Setup/Studio console — `nav_webhooks`, four list views, `userActions` create/edit/delete | REST `/api/v1/data/sys_webhook` (gated) | `get` `list` `create` `update` `delete` |
-| Operator predicate write — "deactivate every webhook on an object" (#4639) | REST `updateMany`/`deleteMany` (gated on `bulk`) | `bulk` |
-| `AutoEnqueuer`, `bootstrapDeclaredWebhooks`, the provenance stamp, `redeliver-guard`, the secret sweep | `engine.*` and lifecycle hooks — ObjectQL directly, which never consults `enable.apiMethods` | ungated |
-
-Every primitive is required by a real consumer, so the set is all six — whose
-operation closure is what the absent block already produced. Nothing that was
-reachable becomes unreachable, and `/me/permissions` reports the identical
-`apiOperations` array. No caller needs to change anything.
-
-⛔ **Do not read this as the read-surface narrowing those three cards asked
-for.** It is not one, and `apiMethods` cannot be one here: `url` (#8025 —
-won't-fix on masking, because the URL is the routing key an operator must be
-able to see, search, sort and edit) and a legacy row's un-migrated
-`definition_json.headers` (#7986 — still read, and warned about, by
-`readLegacyHeaders`) are served by `get`/`list`, which is exactly what the admin
-console requires. Any set that removes them removes the admin surface too. The
-sibling `sys_http_delivery` can hold `['get','list']` because it is engine-owned
-and never authored; `sys_webhook` is a first-class admin authoring surface.
-
-The equality above is pinned in `sys-webhook-api-exposure.test.ts` rather than
-left as a claim, so a later change that does move the surface has to say so.
diff --git a/.changeset/system-overview-by-action-title-parity.md b/.changeset/system-overview-by-action-title-parity.md
deleted file mode 100644
index c8900f1945..0000000000
--- a/.changeset/system-overview-by-action-title-parity.md
+++ /dev/null
@@ -1,43 +0,0 @@
----
-"@objectstack/platform-objects": patch
----
-
-fix(platform-objects): the System Overview by-action table serves its declared title again, and the default locale bundle is now pinned to the source string (#8721)
-
-`widget_recent_events` was converted into an ADR-0021 single-form — a
-dataset-bound breakdown of `sys_audit_log` events by action — but all four
-hand-authored locale bundles kept serving the title the widget had *before* the
-conversion (`Recent Audit Events` / `最近审计事件` / `最近の監査イベント` /
-`Eventos de Auditoría Recientes`). The translation is what renders, so the
-declared string reached nobody in any locale. Its `description` had drifted the
-same way and in the same direction, one field over.
-
-**The duplicate the stale translation was hiding.** With the source string
-restored, the board carried the same label twice: `widget_events_by_type` (a
-pie) and `widget_recent_events` (a table) both declared `Audit Events by
-Action`, over the same dataset and the same dimension. They looked distinct in a
-running instance only because one of them was serving a stale translation. The
-pair now splits on what each adds — the pie keeps `Audit Events by Action` (the
-share picture), the table becomes **`Event Volume by Action`** (the exact
-per-action count, which is what its `values: ['event_count']` produces and what
-its description already said). All four locales are translated to the new
-strings; the widget **ids are unchanged**, so no translation key, persisted
-widget state or dataset binding moves.
-
-**Why nothing caught it, and what now does.** This package's `apps` /
-`dashboards` / `pages` i18n is hand-authored and cannot be regenerated —
-regenerating would delete ~40 runtime-contributed nav translations per locale —
-so it never had the source-tracking the generated half gets from the extractor.
-Every gate over it made a **key-set** claim (`app-nav-translation-parity.test.ts`
-asserts a translation exists and does not outlive its declaration;
-`check:i18n-coverage` ratchets *untranslated* labels; `check:app-nav-i18n` judges
-the merged nav tree), and a key whose value is stale satisfies all of them.
-
-`app-nav-translation-parity.test.ts` now also asserts the **default locale's
-content**: every statically declared app label, description and nav label, plus
-the dashboard's label, description and every widget title/description, must
-appear in `en.ts` **verbatim**. That claim is available for `en` alone because
-`en` is a copy of the source rather than a translation of it — the same
-invariant the generated half already enforces by rewriting its `en` bundle on
-every extract. What a *translated* locale should do when its source string
-changes is a separate product decision and is deliberately not decided here.
diff --git a/.changeset/system-overview-permission-change-tile-removed.md b/.changeset/system-overview-permission-change-tile-removed.md
deleted file mode 100644
index 4b71635101..0000000000
--- a/.changeset/system-overview-permission-change-tile-removed.md
+++ /dev/null
@@ -1,61 +0,0 @@
----
-"@objectstack/platform-objects": patch
----
-
-fix(platform-objects): remove the System Overview board's permanently-empty "Permission Changes" tile (#8148, #7675)
-
-
-
-The System Overview dashboard shipped a "Permission Changes" metric tile
-filtering `sys_audit_log.action = 'permission_change'`. **The tile could never
-report anything but `0`, on any deployment that has ever existed** — the value
-had no writer anywhere in the repo. There are exactly two `sys_audit_log`
-writers: `plugin-audit`'s generic hook writer, whose `actionFor` maps
-afterInsert/afterUpdate/afterDelete to `create`/`update`/`delete` and nothing
-else, and `plugin-auth`'s admin user-import. Neither has ever emitted
-`permission_change`. #8147 then retired the value from the action enum outright,
-so the tile's filter now names a value the platform does not even declare.
-
-**An empty tile on a compliance surface is worse than a missing one.** A
-permanently-`0` "Permission Changes" count does not read as "this platform does
-not track permission changes" — it reads as a *negative finding*: an auditor
-concludes the platform watched for permission changes over the selected window
-and found none. The number was live and the query was real; the question it
-answered was one no row could ever be an answer to. 审计面宁窄勿谎 — a narrow
-audit surface beats a lying one.
-
-**Removed rather than refiltered onto a live action.** Permission and role edits
-*are* captured today, as ordinary `create` / `update` rows written by the generic
-hook against the permission objects — so the honest lens on them is `object_name`
-on the audit list view, a row-level question rather than a single-number KPI.
-Approximating one as a tile would have put a second not-quite-true number on the
-same board. The two surviving Row 2 tiles ("Login Events", "Config Changes")
-split the 12-column row in half instead of leaving a gap where the removed tile
-sat.
-
-The by-action tile's description stops naming `permission` among its example
-actions, in the source **and in all four locale bundles** — the translations are
-the strings actually served, so correcting only the source would not have reached
-a single user.
-
-⚠️ **`import` is deliberately untouched.** It was named in the same ruling as
-`permission_change`, but its retirement premise was falsified during #8147: it
-has a live writer (`plugin-auth`'s admin user-import writes a run-level row) and
-a shipped list view that filters it. Removing it from the dashboard while the
-platform still emits it would produce the exact inverse defect — an audit action
-that can be written but cannot be found.
-
-Both directions are pinned. A tombstone refuses any board widget filtering a
-retired action value, with a live-action control so it cannot pass on a board
-that has no widgets or whose predicates moved. The app/dashboard translation
-parity test gains the **reverse direction it was missing** for dashboard widgets
-— it asserted every declared widget has a translation, but nothing stopped a
-translation outliving its widget, which is precisely what these four locale
-entries would have done.
diff --git a/.changeset/system-write-organization-stamp.md b/.changeset/system-write-organization-stamp.md
deleted file mode 100644
index 474f00a09e..0000000000
--- a/.changeset/system-write-organization-stamp.md
+++ /dev/null
@@ -1,79 +0,0 @@
----
-"@objectstack/objectql": patch
-"@objectstack/spec": patch
----
-
-fix(engine-core): a system-context insert on a tenant-scoped object resolves the install's organization the way a session write does, or is refused — the runtime producer of the autonumber fork #8686's backfill cannot reach (#8844)
-
-
-
-#8686 fixed **one** producer of untenanted rows — the seed loader — and shipped
-a one-shot backfill for what it had already written. This card is the **other
-producer, which is still running**: an ordinary application write made under a
-system execution context (a hook, a scheduled job, a custom endpoint, a
-`runAs: system` flow). A backfill cannot reach it, because it mints a fresh
-duplicate on every tick — which makes #8686's repair **self-undoing on any
-install with server-side automation**, i.e. every business app.
-
-**Measured on 17.0.0 GA**, a single-tenant EHR/MES install with ~44 autonumbered
-objects: two records, same object, same install, the **same** value on a field
-the app declared `unique`, with no error and no warning. The `notification` case
-shows both producers side by side — `NT-00002 .. NT-00011` each existing twice,
-copy A written by the "maintenance overdue" cron job, copy B by a user action.
-
-**Mechanism.** A session write carries the caller's active organization, the SQL
-driver stamps it onto the row (`injectTenantOnInsert`), and the autonumber
-counter reads it back off the row (`fillAutoNumberFields`, resolving
-`row[tenantField] ?? options.tenantId ?? null`). A system-context write carries
-none, so the column lands `NULL` and the counter files the row under the
-`__global__` pseudo-tenant. One object then runs two counters that cannot see
-each other, each correct within its own scope, and the partitioned unique index
-— `(COALESCE(organization_id, '__global__'), )`, ADR-0120 D3 — cannot see
-across the two partitions either.
-
-⛔ **Not a counter bug**, and not fixed by making the allocator smarter: both
-counters are already correct within their own scopes (the reasoning #8686
-recorded, unchanged). The defect is upstream of the counter.
-
-**The fix, per the 2026-08-15 maintainer ruling (Option 1)** — a system-context
-write resolves the install's organization the way a session write does, at the
-engine's stamp resolution, so every driver is covered at the source (which
-matters here because `fillAutoNumberFields` is duplicated in `driver-sql` and
-`driver-turso`; neither driver changed):
-
-- **Single-tenant, exactly one organization ⇒ derive and stamp.** The
- `__global__` fork stops being minted by hooks, cron and system endpoints.
-- **Multi-organization ⇒ carry an explicit organization or be REFUSED LOUDLY**,
- never silently defaulted. A walled posture (`group` / `isolated`), or a
- `single` posture whose data holds several organizations, has no derivable
- answer — the refusal is `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (500,
- registered in the ADR-0112 ledger), thrown before anything reaches the driver,
- and its message names the condition, what would otherwise have been written,
- and both remedies.
-- **Already-minted duplicates are reported, never rewritten** — the #8686
- posture, ruled again here. Nothing in this change renumbers anything.
-
-**Three populations are outside the rule by construction, not by exemption**, so
-that the refusal cannot break unattended automation that was never at risk:
-objects with no organization column, objects declaring `tenancy: { enabled:
-false }` (ADR-0066 — the *declared* way to hold org-less rows, rather than a
-per-write bypass flag) and federated objects (ADR-0015); the platform namespaces
-`sys_` / `cloud_` / `ai_`, whose rows are deliberately global (#8672's reasoning,
-which this ruling confirms holds for platform objects and does **not** generalize
-to application objects); and any write that already carries an organization — on
-the execution context, on the record, or stamped by a `beforeInsert` hook.
-
-**First boot is untouched:** before any organization exists there is nothing to
-derive and no second partition to fork away from, so those rows still land
-org-less for #8686's `sys_organization`-insert handoff to adopt.
-
-Scoped to **insert**, deliberately: the ruling's yardstick is "the way a session
-write does", and stamping the organization is an insert-side mechanism — an
-update neither stamps it nor can fork a counter.
diff --git a/.changeset/tall-jars-invent.md b/.changeset/tall-jars-invent.md
deleted file mode 100644
index 712099056c..0000000000
--- a/.changeset/tall-jars-invent.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@objectstack/metadata-protocol': patch
----
-
-Global search (`GET /api/v1/search`) now resolves searchable fields the same way `$search` does, so the ⌘K palette recalls what the list quick-search recalls (#7643)
-
-`searchAll` built its own filter instead of going through the engine's ADR-0061 `$search` expansion, which made the palette's recall a strict subset of the executor's. It now hands the engine `search: ` per object and lets one expansion resolve the fields and compile the clause.
-
-What a caller observes changing on `GET /api/v1/search` — both are widenings; no query that returned a hit before returns fewer:
-
-- **Pinyin/initials recall now works on this endpoint.** Where the deployment provisions the hidden `__search` companion column (`OS_SEARCH_PINYIN_ENABLED`), latin terms are OR-ed against it, so `hnkj` and `huaningkeji` now return the CJK-named record that `POST /api/v1/data/:object/query {"search":"hnkj"}` already returned. Previously: 0 hits.
-- **Which columns are scanned now follows the object, not a field flag.** Resolution is the object's declared `searchableFields`, else the auto-default (display/name field plus short-text and enum fields) — the set `searchableFields` documents itself as governing. The endpoint previously scanned only text-typed fields carrying the field-level `searchable: true` flag, falling back to the title field alone, so most objects were searched on one column. Hits from a second column (an email, a description, a select's label) are new.
-- Enum (`select`/`status`) columns are now matched by option LABEL, and virtual `formula` fields are excluded, both as on the executor path.
-- **The endpoint no longer substring-scans primary keys.** An object whose only text-typed column is `id` — system tables, junction tables, append-only logs — used to fall through to "the first text-typed field" and be queried as `{id: {$icontains: term}}` on every keystroke. Such objects are now skipped, as `$search` already skipped them (#4483). Callers relying on a bare `id` fragment matching through this endpoint will no longer get that hit; query the record by id instead.
-
-Unchanged: which objects are swept and their opt-outs (`enable.searchable`, `enable.apiEnabled`, the `sys_*` skips), the per-object and overall caps, ordering, RLS/RBAC enforcement, and the response shape. The `$search` executor path itself is untouched. A record matched only through the pinyin companion has no `snippet` — no source column contains the typed term.
-
-Also corrects the stale case declaration on this path (#7850): the doc comment said "case-insensitive LIKE" while the sentence below it named `$contains`, which #4706 Q2 = A defines as case-**sensitive**. Matching folds case via `$icontains`; behaviour is unchanged by that edit.
diff --git a/.changeset/tall-maps-declare.md b/.changeset/tall-maps-declare.md
deleted file mode 100644
index 46ebac4551..0000000000
--- a/.changeset/tall-maps-declare.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@objectstack/spec': minor
----
-
-Add the `map` visualization config block to `ListViewSchema` — the eighth
-per-visualization block, alongside kanban / calendar / gantt / gallery /
-timeline / chart / tree. `ListMapConfigSchema` (named like
-`ListChartConfigSchema`, because the automation `map` flow node already exports
-`MapConfigSchema`) declares the map renderer's documented read surface:
-`latitudeField`, `longitudeField`, `locationField`, `titleField`,
-`descriptionField`, `zoom` (1-20), `center` (`[latitude, longitude]`). All keys
-are optional and none carries a default — when no camera is declared the
-renderer fits the camera to the queried records. Before this block a
-`type: 'map'` list view could not declare its field mapping at all
-(`ListViewSchema` is strict), so a marker title field other than the renderer
-default `name` was unreachable — the showcase task map rendered every marker
-title as `undefined`. The showcase task map view now declares
-`map: { titleField: 'title', locationField: 'location' }`.
diff --git a/.changeset/template-spec-version-sync.md b/.changeset/template-spec-version-sync.md
deleted file mode 100644
index ca530b2c3e..0000000000
--- a/.changeset/template-spec-version-sync.md
+++ /dev/null
@@ -1,64 +0,0 @@
----
-"create-objectstack": patch
----
-
-fix(create-objectstack): the blank template's `specVersion` stops shipping eleven majors stale, and the version-time sync covers every declared surface on every template (#9264)
-
-The one bundled template declared the platform it targets in **two** places that
-disagreed by eleven majors:
-
-| file | key | was |
-|:--|:--|:--|
-| `objectstack.manifest.json` | `specVersion` | `^6.0.0` |
-| `objectstack.config.ts` | `engines.protocol` | `^17` |
-
-`scripts/sync-template-versions.mjs` re-stamped the config key and the template's
-`@objectstack/*` dependency ranges, and **never opened the manifest at all**. So
-`engines.protocol` tracked every major bump while `specVersion` sat at the value
-it held when the script was written — and a green `sync-template-versions` run
-was never evidence about it, because the script's failure mode was loud for the
-keys it covered and mute for the key it did not.
-
-**This is not confined to the registry contract.** `create-objectstack` copies
-the manifest into every scaffolded project, rewriting `name`, `displayName` and
-`namespace` and dropping `description` — it has never touched `specVersion`. So
-every project scaffolded since v7 was stamped with a `^6.0.0` spec range while
-installing `@objectstack/spec@^17.0.0`.
-
-**The two keys are two facts, and the fix keeps them apart.** `engines.protocol`
-is the ADR-0087 D1 runtime handshake range and carries the protocol major
-(`^17`). `specVersion` is documented by `TemplateManifestSchema` as the
-"Compatible `@objectstack/spec` semver range" and carries the package range
-(`^17.0.0`) — the same value the script already writes into the template's own
-`@objectstack/spec` dependency, so the manifest and the `package.json` now state
-one fact once. They agree on the major only because the spec package's major and
-the protocol major are kept in lockstep; they are stamped from two different
-values.
-
-Deleting the key was not available: `specVersion` is **required** by
-`TemplateManifestSchema`, and every shipped manifest is parsed against it by
-`check:template-manifests`.
-
-**Two structural changes, because one-key-one-file coverage is what let this
-sit:**
-
-- the sync script's file list is now **discovered**, not hard-coded — templates
- are found by walking `src/templates/`, the same way `check-template-manifests`
- finds the manifests it parses, so a second template is covered on the day it
- lands;
-- **every stamp is required**. A template whose file is missing, whose stamp is
- absent, or whose `package.json` declares no `@objectstack/*` dependency is a
- hard failure naming the path — never a skip. A skipped stamp is
- indistinguishable from a synced one in the log, which is the invisibility this
- fixes.
-
-The manifest is rewritten as **text** rather than parsed and re-serialized:
-`objectstack.manifest.json` keeps `scaffold.variables` compact on one line, and
-`JSON.stringify(…, null, 2)` would reformat unrelated structure on every release.
-
-CI coverage lands as four per-template ratchets in `template-consistency.test.ts`,
-generalized off `blank` onto the same directory walk — including the invariant
-that catches this exact class: the manifest's `specVersion` must equal the
-`@objectstack/spec` range the template actually installs. Either file alone can
-be self-consistently stale; only comparing them catches a stamp that covered one
-and not the other.
diff --git a/.changeset/temporal-filter-comparand-refused-at-engine-door.md b/.changeset/temporal-filter-comparand-refused-at-engine-door.md
deleted file mode 100644
index 9345ea652a..0000000000
--- a/.changeset/temporal-filter-comparand-refused-at-engine-door.md
+++ /dev/null
@@ -1,47 +0,0 @@
----
-"@objectstack/core": patch
-"@objectstack/objectql": patch
-"@objectstack/service-analytics": patch
----
-
-fix(objectql): a temporal filter comparand the platform cannot interpret is refused at the engine door instead of answering 200 with zero rows (#8690)
-
-
-
-A `datetime` / `date` / `time` field filtered with a bare string the platform
-cannot read — `last_30_days`, `not-a-date-at-all` — was bound **as written**
-all the way to the driver, where the comparison is false for every row. The
-caller received `HTTP 200`, an empty result set, and nothing to indicate the
-filter was meaningless. An unknown `{placeholder}` in the same position was
-already refused loudly (`FILTER_TOKEN_UNKNOWN` / 400, listing the resolvable
-tokens), so one API answered two shapes of unusable comparand two different
-ways.
-
-It is concretely reachable rather than theoretical: `last_7_days` /
-`last_30_days` / `last_90_days` are **declared preset names** in the dashboard
-schema. The shipped console lowers them to `{N_days_ago}` macros before they
-reach the API, so the console path was always safe — but a saved report, an
-integration, an MCP client or an AI-authored query sends the preset name itself
-and got a silent zero. An empty chart is the hardest failure to debug: it is
-indistinguishable from "there is genuinely no data".
-
-Such a comparand is now refused at the ObjectQL engine's single filter
-collection point, with `code: 'INVALID_FILTER'` and `status: 400`, naming the
-field, the value, the key path and the spellings that would work. That seam is
-the one place holding the caller's comparand and the field's **declared type**
-at the same moment, and every verb (`find` / `findOne` / `count` / `aggregate`
-/ `update` / `delete`) and both filter spellings (the array sugar and the
-lowered condition) pass through it, so all four backends inherit one answer
-rather than four. `NativeSQLStrategy` additionally **declines** such a query so
-the raw-SQL analytics path falls through to that door instead of binding the
-value into its own statement.
-
-Deliberately unchanged, each by ruling: a `{placeholder}` keeps its existing
-refusal one layer down (the door runs before token resolution and steps around
-them, so `{30_days_ago}` still resolves normally); non-string comparands are
-untouched (a number is epoch milliseconds, a `Date` is an instant); and the
-**empty string** keeps today's behaviour exactly — it binds as `''` and matches
-every non-null row, which is a separate question that remains its own card.
diff --git a/.changeset/tenancy-organization-field-stamp-only.md b/.changeset/tenancy-organization-field-stamp-only.md
deleted file mode 100644
index 72204020cf..0000000000
--- a/.changeset/tenancy-organization-field-stamp-only.md
+++ /dev/null
@@ -1,42 +0,0 @@
----
-"@objectstack/spec": minor
-"@objectstack/plugin-audit": minor
-"@objectstack/platform-objects": patch
----
-
-feat(spec): stamp-only `tenancy.organizationField` — audit rows can follow the record's organization on objects that must stay unwalled (#8778, closes the #8707 remainder)
-
-The platform had one answer to "what is this object WALLED by"
-(`tenancy.tenantField`) and no answer to "which column says who this row is
-ABOUT". For ordinary objects the two coincide; for credential tables they
-deliberately do not — `sys_api_key` records the organization a key
-authenticates into under `active_organization_id` precisely so the credential
-table is not org-walled (#8287). #8777's schema-resolved audit stamping could
-therefore reach every shipped object except the one that motivated it, and
-revocation rows on `sys_api_key` kept stamping the revoker's organization.
-
-`TenancyConfigSchema` now accepts an optional `organizationField` — a
-READ-NEUTRAL, STAMP-ONLY declaration (maintainer-ruled option A on #8778):
-
-- The audit writer's `resolveRecordOrganizationField` consults it first, ahead
- of the ADR-0066 `enabled: false` opt-out — an author declaring it on an
- unwalled object is stating exactly that the audit trail should follow the
- record's own organization even though no wall does. It is honoured only when
- the object really has the field (the #5315 guard `tenantField` carries).
-- No read path reads it: `applyTenantScope`, `injectTenantOnInsert`,
- `computeTenantLayer0Filter` and `resolveInjectedSystemColumns` are all
- measured blind to it, and that read-neutrality is pinned by tests beside
- each. Declaring it never walls an object and never hides rows.
-- ⛔ Scope pin from the ruling: this is ONE stamp-only key, not the opening
- move of a general field-roles mechanism. A consumer other than audit
- stamping needs its own ruling before reading it.
-
-`sys_api_key` now declares
-`tenancy: { enabled: false, organizationField: 'active_organization_id' }`,
-so revoking another user's key from a different active organization lands the
-audit row behind the wall of the KEY's organization — where the tenant admin
-who can act on it reads it. The `enabled: false` is measured
-behavior-identical to the previous absent block for this object on every read
-path (injection bails on `managedBy: 'better-auth'` first; the SQL driver's
-tenant field resolves null either way; Layer 0 is exempt either way; the
-memory/mongo boot guards count only an explicit `enabled: true`).
diff --git a/.changeset/tenant-index-follows-the-wall.md b/.changeset/tenant-index-follows-the-wall.md
deleted file mode 100644
index df248d1f10..0000000000
--- a/.changeset/tenant-index-follows-the-wall.md
+++ /dev/null
@@ -1,49 +0,0 @@
----
-"@objectstack/objectql": patch
----
-
-fix(objectql): the tenant-scope index follows the WALL's derivation, so an object that opts out with `systemFields: false` while declaring its own `organization_id` stops running the wall predicate unindexed (#8608)
-
-
-
-Two places answered *"is this object tenant-scoped?"* and read different
-declarations. The platform's tenant-scope index was gated on the spec's
-**injection plan** (`resolveInjectedSystemColumns(...).tenant`), while
-plugin-security's Layer 0 wall derives `tenancyDisabled` from exactly two
-clauses:
-
-```ts
-tenancy.enabled === false || systemFields.tenant === false
-```
-
-`systemFields: false` — the hard object-level opt-out — is in the plan and in
-neither of those clauses. So an object using that opt-out **while declaring its
-own `organization_id`** had `organization_id = ` AND-composed onto
-essentially every read, with no index behind it: the deployment's hottest
-predicate, unindexed. Not a security hole — isolation still held; it was slow,
-not wrong, which is why nothing surfaced it.
-
-**Both halves were measured end to end** rather than read off the source. On the
-pre-fix tree, for one such object, the registry answered `indexes: null` while
-`SecurityPlugin#getReadFilter` answered `{ organization_id: 'org-1' }` for an
-ordinary member.
-
-The wall's derivation is authoritative and the index now follows it: the index
-is declared when tenancy is not disabled by the wall's two clauses **and** the
-object carries `organization_id` — whether the platform provisions the column or
-the author declared it. `managedBy: 'better-auth'` is deliberately not re-added
-as a third clause, because the wall does not read it either; the one shipped
-platform object whose answer changes is `sys_member`, which is walled on
-`organization_id` and whose only tenant-leading index was the composite
-`['organization_id', 'user_id']`.
-
-Unchanged, and pinned beside the fix: `systemFields.tenant: false` and
-`tenancy.enabled: false` still declare no index (the wall composes no predicate
-there, so an index would serve nothing), a single-tenant deployment still
-declares none at all, an author's own tenant index still suppresses the
-platform's, and the hard opt-out still injects no platform columns — only the
-index decision was ever owed at that exit.
diff --git a/.changeset/tenant-plan-docblock-entitlement-fold.md b/.changeset/tenant-plan-docblock-entitlement-fold.md
deleted file mode 100644
index f9d6484beb..0000000000
--- a/.changeset/tenant-plan-docblock-entitlement-fold.md
+++ /dev/null
@@ -1,31 +0,0 @@
----
-"@objectstack/spec": patch
----
-
-docs(spec): `TenantPlanSchema` doc block states the entitlement-layer fold, not normalization (#9345)
-
-`TenantPlanSchema`'s doc block claimed that an unrecognized plan code is folded
-to the free tier by "the cloud distribution's normalization." That was
-measured wrong on two counts as of the cloud#1380 ruling (2026-08-16, landed
-in cloud PR #1417, merged 2026-08-17):
-
-- The fold happens at the **entitlement layer** (e.g. `isFreePlan`), never in
- normalization — `sys_environment.plan` keeps the raw value (case-normalized
- only), so an unrecognized tier stays distinguishable from the free tier to
- any reader, log line, or operator. Writing it as normalization is exactly
- what cloud#1389's red line forbids: normalize the spelling, never the
- vocabulary.
-- Before the ruling landed, only the control-plane `planKey` reader folded
- unknown codes to free; the tenant-runtime `isFreePlan` reader granted paid
- access to an unrecognized code. As of cloud PR #1417 both mirrors fold.
-
-The corrected doc block also states, explicitly, what it must not say: the two
-mirrors' vocabularies are not merged into one list (cloud#1380 lands a
-pinned *copy*; unifying them is cloud#1418, ruled but not yet landed, and a
-SHA-pinned image can predate a vocabulary entry even after that lands), and
-it carries the ruling's operational premise (new plan tiers are minted
-rarely, images roll before a new tier goes on sale) so the spec text does not
-contradict cloud's `isFreePlan` docstring, which states the same premise.
-
-Doc-block prose only — `TenantPlanSchema` still accepts any string and
-enforces no vocabulary; acceptance behavior is unchanged.
diff --git a/.changeset/tidy-pandas-repeat.md b/.changeset/tidy-pandas-repeat.md
deleted file mode 100644
index c3eac689c1..0000000000
--- a/.changeset/tidy-pandas-repeat.md
+++ /dev/null
@@ -1,23 +0,0 @@
----
-"@objectstack/metadata-protocol": patch
----
-
-`auditMetaItem` no longer reports a failed audit read as an empty audit trail
-
-The `catch` closing the audit read in `ObjectStackProtocolImplementation.auditMetaItem`
-was unqualified. Its comment named two benign causes — the `sys_metadata_audit` table not
-being provisioned (legacy environments) and a host engine that exposes no `find` — but the
-clause took every other cause with them: a connection drop, a permission denial, a
-timeout, a malformed row, a query bug. Each was reported to the caller as the well-formed
-statement `{ events: [] }`, i.e. "this item has no audit entries".
-
-This is the compliance surface behind `GET /api/v1/meta/:type/:name/audit`, which exists
-so Studio's audit-log tab can show who tried what and whether a lock blocked it, so an
-empty answer reads as *nobody touched this item*. Because the swallowed failures are
-transient, the same item could report a full trail one minute and a clean one the next.
-
-Both benign causes still answer `{ events: [] }` exactly as documented. Every other read
-failure now raises `SERVICE_UNAVAILABLE` / 503 carrying the driver error as `cause`, which
-the route's existing error handler turns into an honest 5xx — the same treatment the
-sibling `listCommits` and `getMetaItem` reads in this package already give (ADR-0110 D3: a
-miss and a fault are different facts).
diff --git a/.changeset/tidy-pugs-shave.md b/.changeset/tidy-pugs-shave.md
deleted file mode 100644
index 06a36a21db..0000000000
--- a/.changeset/tidy-pugs-shave.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@objectstack/metadata-protocol': patch
----
-
-Remove the four dead `'objects'` spelling tolerances in the metadata protocol's object registry and storage seams.
-
-`applyObjectRegistryMutation`, `applyRegistryWriteThrough`, `ensureObjectStorage` and `dropObjectStorage` each admitted a plural `'objects'` type key, and the first of them *registered under it* — the spelling-tolerant-lookup shape `canonicalMetaType`'s header rejects, and the one that previously let a plural registry entry shadow an entire code-authored listing.
-
-All four are unreachable: every producer folds the type through `PLURAL_TO_SINGULAR` / `canonicalMetaType` before these seams see it. No behaviour changes for any caller that folds — which is all of them. What changes is the failure mode of a future caller that does *not* fold: it no longer silently registers an object under a plural key, so `assertObjectRegistered` fails closed with a loud, recoverable error instead.
-
-Folding at the producer remains the rule; these guards were never a second line of defence.
diff --git a/.changeset/tough-jars-invite.md b/.changeset/tough-jars-invite.md
deleted file mode 100644
index 767777ebd4..0000000000
--- a/.changeset/tough-jars-invite.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
-'@objectstack/metadata-protocol': patch
----
-
-Stop `GET /api/v1/meta/:type/:name/diff` serving stored credential values.
-
-`diffMetaItem` compared two stored metadata bodies and emitted the raw values it
-found, so a `datasource` row whose credential rotated between versions returned
-both the old and the new password in cleartext (inline `config.password` and the
-password component of `config.url` alike).
-
-The diff is still computed on the RAW bodies — a credential rotation continues to
-report its path as changed — but the emitted `value` / `from` / `to` are now taken
-from the type's redacted projection of those same bodies, on both sides. Types
-with no registered redactor are unaffected and keep serving their values by
-reference.
diff --git a/.changeset/translation-staleness-source-hash.md b/.changeset/translation-staleness-source-hash.md
deleted file mode 100644
index f493c66f86..0000000000
--- a/.changeset/translation-staleness-source-hash.md
+++ /dev/null
@@ -1,49 +0,0 @@
----
-"@objectstack/platform-objects": patch
----
-
-fix(platform-objects): a translated Setup/Studio/Account label whose source string has been edited underneath it now serves the source text instead of the stale translation (#8765)
-
-The `apps` / `dashboards` / `pages` half of this package's i18n is hand-authored
-per locale. Every gate over it judges **presence or ownership** —
-`app-nav-translation-parity.test.ts` (a translation exists for every declared
-id, and none outlives its declaration), `check:i18n-coverage` (ratchets
-*untranslated* labels), `check:app-nav-i18n` (a label per locale on the merged
-nav tree). A translated value that has gone **stale** satisfies every one of
-them: it is present, it is owned, it is not untranslated.
-
-So a source-string edit left `zh-CN` / `ja-JP` / `es-ES` serving the previous
-translation indefinitely, under a fully green build — which is how
-`widget_recent_events` shipped its pre-conversion title in all four locales.
-Pinning `en` to the declared source did not create that drift, but it removed
-the one accidental symptom that made it visible: the drift stopped being
-uniform across four bundles and became locale-specific, invisible to every
-reviewer who reads the product in English.
-
-**Ruled Option B** (#8765): record the source hash at translation time; a hash
-mismatch marks the translation stale, and stale falls back to the source text.
-
-- Each translated locale ships a `.source-hashes.ts` table recording,
- per leaf, the digest of the `en` source string that leaf was translated from.
- `setup.translation.ts` compares them against the current source when it
- assembles the bundle the kernel is handed.
-- **Edit a source string** ⇒ that leaf falls back to the source text in every
- locale that had translated it.
-- **Update one translation** (its value *and* its recorded hash) ⇒ **that locale
- alone recovers**; the others keep falling back.
-- **A leaf with no recorded hash is legacy-trusted**, not stale. The tables were
- backfilled once from the then-current source, so no existing translation
- degraded when this landed.
-
-**No new failure mode, and no new gate.** The fallback substitutes the source
-string rather than deleting the key, so no key set moves; a translated locale
-carrying the source string verbatim is exactly what the extractor already
-writes for an untranslated key under `--fill=default`, and exactly what the
-resolver's locale chain has always rendered. Staleness degrades what is
-*served* — it never fails a build, which would put a four-locale translation
-task in front of every one-word source edit.
-
-Scope is the hand-authored sections only. `objects` / `metadataForms` are
-generated, and the hole cannot occur there: `os i18n extract` rewrites the `en`
-bundle from the source on every run and does not merge the default locale, so a
-source edit either lands in the generated bundle or fails `check:i18n` as drift.
diff --git a/.changeset/ui-record-blocks-unknown-keys-refused.md b/.changeset/ui-record-blocks-unknown-keys-refused.md
deleted file mode 100644
index 440ba71b11..0000000000
--- a/.changeset/ui-record-blocks-unknown-keys-refused.md
+++ /dev/null
@@ -1,90 +0,0 @@
----
-"@objectstack/spec": minor
----
-
-feat(spec): declare `record:alert` / `record:quick_actions` / `record:history` / `record:discussion` in `ComponentPropsMap` — undeclared keys on the four are refused (#8744)
-
-**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
-launch-window convention ships it as `minor`; the migration prescription is
-registered under protocol major 18, where `os migrate meta` users will look).
-
-These were the four `record:*` types #8691's rail fix left in the rail's own
-pre-fix position: a registered objectui renderer, a `PageComponentType` entry
-and a console palette slot (bar `record:discussion`, which was authorable only
-through the type union's open string arm), and no `ComponentPropsMap` row — so
-the #5068 component-props gate's dispatch skipped them as unregistered and
-every authored key rode through. A typo'd `severty` on the platform's own
-banner surface parsed, typechecked, validated, built and shipped as a silent
-no-op while sibling components in the same file drew loud diagnostics.
-
-The new rows are strict and declare exactly what the renderers read, measured
-from their read points at the objectui pin — not from the registrations'
-declared-input lists, which are wrong in both directions here:
-
-- `record:alert` — `severity?`, `title?` / `body?` (string **or inline locale
- map** — this renderer resolves both through `pickLocalized`, the opposite
- verdict from the rail's literal-string `title`, measured the same way),
- `visible?` (boolean | CEL string | `{ dialect, source }` envelope), `icon?`
- (read here, unlike the rail's), `action?` `{ actionName, label?, variant? }`,
- `dismissible?`, `dismissKey?`. `visibleWhen` / `visibility` rename to
- `visible` as aliases — this is the one record component whose props-level
- predicate is real, so the wrong-layer visibility guidance does not apply.
-- `record:quick_actions` — `actionNames?`, `requiredPermissions?`, `location?`
- (the spec's own `ActionLocationSchema`, retirement prescriptions included),
- `align?`, `inline?`, `variant?` / `size?` (the Button primitive's delivered
- vocabulary). `actions` is refused with a prescription (as a name list it is
- `actionNames`; as inline defs it is the host synthesizer's runtime channel).
- `aria` is refused rather than declared: the renderer reads `aria.label`, a
- spelling the shared `AriaPropsSchema` refuses, and reads nothing else of the
- bag — declaring either spelling would be declared-but-unenforced surface
- (the renderer-side fix is objectui's, filed).
-- `record:history` — `limit?`, `emptyText?` / `unknownUserText?` (literal
- strings — the timeline renders them raw; a locale map would paint
- `[object Object]`). `entries` / `loading` are refused as the host's data
- channel: omit them and the block self-fetches the record's `sys_activity`
- history.
-- `record:discussion` — `record:chatter`'s own row, deliberately the same
- schema object (one renderer registered under two names must keep one accept
- face), plus a `PageComponentType` entry so the name is no longer a
- string-arm stowaway.
-
-**What stays accepted:** every declared key byte-identically — the platform
-`sys_user` page's banner and self-service action bars and the showcase task
-page pass with zero findings. No row carries a schema default (renderer
-fallbacks stay the renderer's facts). The one parse-time normalization is
-`ExpressionInputSchema`'s own: a bare-string `visible` becomes the canonical
-`{ dialect: 'cel', source }` envelope.
-
-## FROM → TO
-
-```ts
-// before — parsed green everywhere; the banner styled itself `info` anyway
-{
- type: 'record:alert',
- properties: {
- severty: 'warning', // silent no-op typo
- title: 'Awaiting review',
- },
-}
-
-// after — the typo is a publish-time refusal naming the rename; write the
-// measured shape
-{
- type: 'record:alert',
- properties: {
- severity: 'warning',
- title: 'Awaiting review',
- visible: "record.status == 'in_review'",
- },
-}
-```
-
-There is deliberately no automatic rewrite: an undeclared key is either a
-spelling of a declared one (the rejection names the rename) or names a
-capability the renderer does not deliver, and blessing either would be
-declared-but-unenforced surface (ADR-0078). `os migrate meta` surfaces the
-change as a structured TODO (semantic entry
-`ui-record-blocks-unknown-keys-refused`, protocol major 18 — this refusal is
-not part of the v17.0.0 cut).
-
-
diff --git a/.changeset/ui-reference-rail-unknown-keys-refused.md b/.changeset/ui-reference-rail-unknown-keys-refused.md
deleted file mode 100644
index 870485e942..0000000000
--- a/.changeset/ui-reference-rail-unknown-keys-refused.md
+++ /dev/null
@@ -1,74 +0,0 @@
----
-"@objectstack/spec": minor
----
-
-feat(spec): declare `record:reference_rail` in `ComponentPropsMap` — undeclared rail keys are refused (#8691)
-
-**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
-launch-window convention ships it as `minor`; the migration prescription is
-registered under protocol major 18, where `os migrate meta` users will look).
-
-`record:reference_rail` had a registered renderer, a `PageComponentType` entry
-and a console palette slot, but no row in `ComponentPropsMap` — so the #5068
-component-props gate's dispatch skipped it as unregistered and every authored
-key rode through. Measured on 17.0.0 GA end to end: a planted entry `filter`
-passed tsc, `objectstack validate` and `objectstack build`, shipped verbatim in
-`dist/objectstack.json`, and the rendered rail kept counting and listing
-unfiltered rows — while the very same build loudly reported
-`record:related_list` keys in the same file.
-
-The new row is strict and declares exactly the shape the renderer reads
-(measured from its read points at the objectui pin, not from its TS
-interface): `entries[]` of `{ objectName, relationshipField, title?, limit?,
-displayField? }` plus a component-level `hideEmpty`.
-
-**What is refused:** any key the shape does not declare, with a prescriptive
-message — the planted `filter` (the rail issues one fixed query per entry;
-`record:related_list` is where `filter` is real), the interface's `icon` (read
-by no render path — declaring it would be declared-but-unenforced surface),
-entry-level `hideEmpty` (a component-level key), and the neighbouring-surface
-spellings `items`/`related` → `entries`, `object` → `objectName`, `label` →
-`title`. `title` is a literal `z.string()` — the renderer paints it as a raw
-React child, so an inline locale map is refused rather than shipped as
-`[object Object]`.
-
-**What stays accepted:** every declared key byte-identically. `limit` and
-`hideEmpty` carry no schema default (the renderer's `3` / `true` fallbacks stay
-the renderer's), so a minimal entry round-trips unchanged.
-
-## FROM → TO
-
-```ts
-// before — parsed green everywhere; the badge kept counting everything
-{
- type: 'record:reference_rail',
- properties: {
- entries: [{
- objectName: 'task', relationshipField: 'project_id',
- filter: [{ field: 'status', op: 'neq', value: 'completed' }], // silent no-op
- icon: 'CheckSquare', // read by nothing
- }],
- },
-}
-
-// after — both keys are publish-time refusals with prescriptions; write only
-// what the renderer reads
-{
- type: 'record:reference_rail',
- properties: {
- entries: [{ objectName: 'task', relationshipField: 'project_id', limit: 3 }],
- hideEmpty: false,
- },
-}
-```
-
-There is deliberately no automatic rewrite: an undeclared key is either a
-spelling of a declared one (the rejection names the rename) or names a
-capability the rail does not deliver — a per-entry `filter` and an inline
-`title` locale map are open capability questions for the console seat, and
-blessing either spelling now would be declared-but-unenforced surface
-(ADR-0078). `os migrate meta` surfaces the change as a structured TODO
-(semantic entry `ui-reference-rail-unknown-keys-refused`, protocol major 18 —
-this refusal is not part of the v17.0.0 cut).
-
-
diff --git a/.changeset/undeclared-field-preflight.md b/.changeset/undeclared-field-preflight.md
deleted file mode 100644
index 3b1aaaf5ab..0000000000
--- a/.changeset/undeclared-field-preflight.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-"@objectstack/objectql": patch
----
-
-Refuse undeclared fields on insert at the schema, and keep bound values out of the write-path logs (#8682)
-
-**A single mistyped field name in a client request no longer writes an entire row's values to disk.** A driver-level write fault is logged by prefixing the fully bound SQL statement — values inlined — to the database's own message, and the logger serializes both `message` and `stack`, so the statement was written twice at ERROR level. Confirmed with planted canaries: the row's values landed in the log alongside the organization id and the acting user id. The insert, update and delete loggers now write the database's own diagnostic — which still names the failing column and the object — with the statement and its bound values cut from both fields. The level, the message and the entry itself are unchanged: a driver fault nobody can debug would be a worse outcome than one logged too loudly.
-
-**An undeclared field is now refused by the object's field map, before anything runs for a request that was already going to be refused.** Previously an unknown key was caught only at the very end, by the driver, after an id, an auto-number, a normalized name, owner/creator resolution, the column defaults and the app's `beforeInsert` hooks had all been produced for it. The auto-number was the durable damage: the refused request consumed a sequence value and left a permanent gap in a document number an end user reads. `insertMany` now culls such a row per row instead of letting it fail the whole batch.
-
-The client-facing answer is deliberately unchanged — the same `400 INVALID_FIELD`, with the same message and the same `field` / `object` — and the rethrown error is untouched, so only what reaches the log has moved. Objects whose field map is absent or empty get no verdict at all, and `id` / `created_at` / `updated_at` stay accepted even when a declaration omits them, matching what the read path already tolerates; in every one of those cases the driver remains the backstop it has always been.
diff --git a/.changeset/undeclared-update-field-door.md b/.changeset/undeclared-update-field-door.md
deleted file mode 100644
index b7a7fdee89..0000000000
--- a/.changeset/undeclared-update-field-door.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-"@objectstack/objectql": patch
----
-
-Refuse undeclared fields on update at the schema, before the `beforeUpdate` hooks run (#8738)
-
-**An undeclared key on `engine.update(...)` is now refused by the object's field map, before anything runs for a request that was already going to be refused.** Previously it travelled the whole update path and was refused at the very end by the driver — measured on both branches of the verb: `driver.update` on the by-id path and `driver.updateMany` on the predicate path each received the mistyped key. The `beforeUpdate` hooks ran first, so a hook that stamps a ledger, calls out, or derives a field executed for a write that was then rejected; in the reproduction the hook's derived value travelled into the statement the driver refused.
-
-**What a caller observes changing.** The refusal itself does not move: an undeclared update key was already rejected, and the client-facing answer is deliberately unchanged — the same `400 INVALID_FIELD` with the same message, `field` and `object`, which `@objectstack/rest` re-emits verbatim. What changes is where the refusal is decided, and therefore what the error carries **inside the process**: an in-process caller of `ObjectQL.update()` that caught the old failure saw the driver's raw error (no `code`, no `status`, its message containing the bound SQL statement) and now sees the ADR-0112 envelope (`code: 'INVALID_FIELD'`, `status: 400`) with a message naming the field. An in-process caller matching on the driver's SQL text — rather than on the envelope — is the one shape that has to change. The write no longer costs a driver round-trip either: the pre-update read is skipped along with the hooks.
-
-The door is the same one `insert()` has carried since #8682 — one condition, one implementation, now with two callers — including its three deliberate no-opinion cases, which are unchanged and reused rather than re-derived: an absent field map, a field map the door sees as empty, and `id` / `created_at` / `updated_at` when a declaration omits them. Schema drift (a declared field whose physical column is missing) stays the driver's to refuse, as before. Nothing is widened; `declared = enforced` (Prime Directive #10) is restored on the second write verb.
diff --git a/.changeset/union-branch-policy-cross-package-parity.md b/.changeset/union-branch-policy-cross-package-parity.md
deleted file mode 100644
index a1e231951d..0000000000
--- a/.changeset/union-branch-policy-cross-package-parity.md
+++ /dev/null
@@ -1,41 +0,0 @@
----
-"@objectstack/metadata-protocol": patch
----
-
-test(metadata-protocol): pin the THIRD union-branch policy copy against `@objectstack/spec` (#8660)
-
-The union-branch selection policy — kind-mismatch drop, fewest-issues ranking,
-`unrecognized_keys` tie-break, declaration-order determinism, depth limit 3,
-branch cap 3 — has three implementations. #8318 (PR #8659) consolidated the two
-inside `packages/spec` into one package-internal module and pinned them with a
-shared-fixture parity test. The third, `zodIssuesToMetadataIssues` in
-`protocol.ts` (the walk behind `saveMetaItem`'s `422 INVALID_METADATA` and the
-read path's diagnostics), was structurally out of that consolidation's reach:
-the shared module is deliberately not a public export (#4001), so a consumer in
-another package cannot import it.
-
-That left this copy exactly where the spec pair sat before #8318 — held in step
-by a header comment and nothing else. A future tie-break or ranking tweak lands
-in `union-branch-policy.ts` for both spec walks at once and silently not for
-this one, and then the same authored metadata gets one prescription from the
-terminal, another from the data API, and a third from Studio: the forked verdict
-#5014 ruled out.
-
-`src/union-branch-policy.cross-package-parity.test.ts` is the enforcement the
-header stood in for. One fixture corpus, one `safeParse` per fixture, three
-walks reached through PUBLIC surfaces only — `formatZodIssue` from
-`@objectstack/spec`, `zodIssuesToFields` from `@objectstack/spec/api`, and this
-package's own copy — compared as ordered `(path, message)` pairs. The corpus
-covers every element of the policy by name, plus a hand-authored expectation per
-fixture so both sides drifting the same way still fails. The two deliberate
-asymmetries (the prose-only omission line, and raw zod codes here vs the
-ADR-0114 catalog on the wire) are asserted in place rather than normalised away.
-
-**`patch`, deliberately not a skipped changeset.** No production line changes,
-no export moves, and every assertion is green on `main` before this lands — but
-the bump floor is right rather than absent, for the same reason
-`legacy-unique-guard-attribution` took one: what ships is a ratchet on
-release-relevant behaviour. The 422 envelope this pins is a published contract
-of `@objectstack/metadata-protocol`, and a consumer reading the CHANGELOG should
-be able to see when its verdict acquired mechanical protection against drifting
-away from the spec's.
diff --git a/.changeset/union-branch-policy-one-implementation.md b/.changeset/union-branch-policy-one-implementation.md
deleted file mode 100644
index 3913a48a9a..0000000000
--- a/.changeset/union-branch-policy-one-implementation.md
+++ /dev/null
@@ -1,47 +0,0 @@
----
-"@objectstack/spec": patch
----
-
-refactor(spec): the union-branch selection policy has ONE implementation, and a parity test that keeps it that way (#8318)
-
-`shared/error-map.zod.ts` (the prose renderer, #4971/#5389) and
-`api/zod-issues-to-fields.ts` (the ADR-0114 D3 wire mapper, #8124) carried the
-SAME union-branch selection policy as two separate implementations —
-kind-mismatch drop, fewest-issues ranking, `unrecognized_keys` tie-break,
-declaration-order determinism, depth limit 3, branch cap 3, and the
-`invalid_key` / `invalid_element` container codes. While the mapper still lived
-in `@objectstack/rest` the duplication was forced; #8124 moved it into this
-package, so the two sat one directory apart with their module headers — and
-nothing mechanical — asking whoever edits one to edit the other.
-
-The policy now lives in one package-internal module,
-`src/shared/union-branch-policy.ts`, which both walks import. It is deliberately
-NOT a public export: it is absent from every barrel, and `api-surface/` and
-`export-origins/` do not move.
-
-The two WALKS stay separate implementations, as they should — one renders
-indented `✗ path: message` prose for a terminal, the other produces
-`{field, code, message}` entries for a JSON envelope, and only the renderer
-emits the trailing "… and N more branches rejected this value" line. That
-asymmetry is now explicit rather than implicit: `selectUnionBranches` returns
-`{selected, omitted}`, the renderer prints `omitted`, and the mapper
-destructures `selected` alone at a commented line, because a `fields[]` entry
-must name a real field and carry a catalog code and an omission count has
-neither.
-
-`src/shared/union-branch-policy.parity.test.ts` is the enforcement the module
-headers lacked: one `safeParse` per fixture feeds BOTH walks, and their outputs
-are compared pair for pair after a normalisation that removes the indent, the
-`✗` glyph and the `(root)` spelling — nothing else. The corpus covers every rule
-of the policy (kind-mismatch drop, all-kind-mismatch, fewest-issues ranking, the
-`unrecognized_keys` tie-break, declaration-order determinism, the depth limit,
-the branch cap, and container descent for both `invalid_key` and
-`invalid_element`), and the one deliberate asymmetry is asserted rather than
-normalised away.
-
-Behaviour is unchanged for every issue zod produces: the ranking, both limits
-and the container-code set are byte-identical to what each walk applied before.
-The single deliberate widening is that the shared policy reads a missing or
-non-array `path` as the root — the wire mapper's already-shipped normalisation,
-now applied to the renderer too, which previously threw on such an issue object.
-No value satisfying the renderer's own `ZodIssueMinimal` type is affected.
diff --git a/.changeset/unique-violation-absence-sentence-superstring.md b/.changeset/unique-violation-absence-sentence-superstring.md
deleted file mode 100644
index 308f96a959..0000000000
--- a/.changeset/unique-violation-absence-sentence-superstring.md
+++ /dev/null
@@ -1,67 +0,0 @@
----
-"@objectstack/types": patch
----
-
-fix(types): `isUniqueViolationError` stops claiming the sentences that say a unique constraint is ABSENT (#8590)
-
-The shared predicate's message limb was a bare `unique constraint`, and a word
-pair is not a condition. Every dialect that can say "this row violated a unique
-constraint" can also say "there is no unique constraint here", and the same two
-words sit adjacent in both — so the predicate answered **true** for errors
-meaning the exact opposite of what it detects. `rest-server.ts` maps that
-verdict to `409 UNIQUE_VIOLATION`, which tells a client to change a value when
-nothing was ever compared, on a status an SDK will not retry.
-
-**Measured on live servers for this fix, all three supported dialect families**
-— SQLite via better-sqlite3, PostgreSQL 16.13 via `pg` 8.22.0, MariaDB 10.11.14
-via `mysql2` 3.23.1, all through knex 3.3.0 — driving each dialect through both
-conditions plus the NOT NULL / FOREIGN KEY near misses:
-
-```
-sqlite ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint
- -> was true, WRONG (the reported defect, #8590)
-postgres there is no unique constraint matching given keys for referenced table "t"
- -> was true, WRONG (42830 — found by this fix's dialect sweep)
-postgres there is no unique or exclusion constraint matching the ON CONFLICT specification
- -> false (the pair is not adjacent here)
-mysql the condition cannot arise: knex compiles to ON DUPLICATE KEY UPDATE,
- which carries no conflict target (confirmed against a live server)
-```
-
-**Postgres was not clean either, and that chose the fix.** #8590 was filed
-reading the collision as SQLite-only, with Postgres escaping "by luck of word
-order". The sweep raised **42830** — a `FOREIGN KEY` referencing a non-unique
-column — where Postgres puts `unique constraint` adjacent in its own absence
-sentence. The card offered two candidate fixes; only one survives 42830. A
-negative lookahead on SQLite's missing-index sentence is a blocklist that can
-only enumerate absence sentences somebody already tripped over, and it answers
-`true` on 42830. So the limb now requires a **violation phrasing** —
-`unique constraint failed` (SQLite) or `violates unique constraint` (Postgres) —
-which restores the module's own stated default, *unrecognised is `false`*, to
-the message channel.
-
-**Both spellings the retired limb covered are preserved exactly**, which was the
-constraint on the fix: the limb was inherited verbatim from the REST branch
-#6250 replaced and covered SQLite's `UNIQUE constraint failed: t.c` *and*
-Postgres' `... violates unique constraint "..."`. The `unique violation`,
-`duplicate key` and `duplicate entry` limbs are untouched, as are the `code` and
-`errno` channels — MySQL's `Duplicate entry` path never went through the
-narrowed limb at all.
-
-**No user-visible behaviour changes today; this closes a latent inversion.** The
-one site compiling a caller-supplied conflict target (`SqlDriver.upsert`)
-recognises the unbacked target *first* in its catch and throws a refusal
-declaring `status: 400`, and `mapDataError` reads `declaredHttpStatus` before it
-reaches the unique-violation branch — so the 409 was gated off the wire by
-ordering, not by the verdict. That ordering was the only thing standing between
-this and a wrong status, which is why the verdict is now pinned rather than left
-to it. A repo-wide scan of every string literal whose verdict moves found no
-consumer relying on the old answer: all of them are prose, a different
-predicate's vocabulary (`looksLikeInternalErrorLeak` keeps its own list), or
-fixtures asserted through the status-passthrough path.
-
-`unbacked-conflict-target.test.ts`'s pin — written by #8567 to point at itself
-rather than go quietly green — is **inverted, not deleted**, and
-`unique-violation-absence-sentences.test.ts` pins the absence sentences per
-dialect in both directions, including the code channel, so re-reading `code`
-cannot undo the message-side fix from the other side.
diff --git a/.changeset/unscoped-multi-delete-refusal.md b/.changeset/unscoped-multi-delete-refusal.md
deleted file mode 100644
index 2e7806dd62..0000000000
--- a/.changeset/unscoped-multi-delete-refusal.md
+++ /dev/null
@@ -1,10 +0,0 @@
----
-'@objectstack/objectql': minor
-'@objectstack/service-storage': patch
----
-
-Restore the #4757 unscoped multi-delete refusal on `sys_attachment` through the wired engine (#9719).
-
-`ObjectQL.registerHook` gains an opt-in `dispatchUnscopedMultiDelete` declaration (valid on `beforeDelete` registrations only — anything else is refused at registration): when a `multi: true` delete arrives with no `where` at all (absent or `null`), the engine's predicate path dispatches the whole-operation context ONCE to declaring registrations — before any matched row is resolved, zero-match included — so a guard about the operation's shape can refuse it. Binding `input.id` on that context is refused (`HookTargetRebindError`, path `'unscoped-multi'`). Undeclared registrations, scoped deletes (including the match-all `where: {}`), and by-id deletes see no new dispatch.
-
-The `sys_attachment` access guard declares the flag, so its documented refusal of a predicate-less multi-delete fires again with its declared envelope (`ATTACHMENT_DELETE_DENIED`, HTTP 403): since the per-row dispatch contract (#5038/#5574) that branch was unreachable, and a predicate-less `multi: true` delete quietly removed every row the caller happened to be entitled to. System-context and context-less programmatic deletes bypass the guard exactly as before.
diff --git a/.changeset/unscoped-search-federated-companion.md b/.changeset/unscoped-search-federated-companion.md
deleted file mode 100644
index 5febcf35f9..0000000000
--- a/.changeset/unscoped-search-federated-companion.md
+++ /dev/null
@@ -1,69 +0,0 @@
----
-"@objectstack/objectql": patch
----
-
-fix(objectql): an unscoped `GET /api/v1/search` stops answering 400 when a federated object is registered and pinyin recall is on — the `__search` companion is no longer declared on objects the platform runs no DDL for (#9469)
-
-On the **stock** showcase configuration an unscoped `GET /api/v1/search` — no
-`objects=` parameter — answered **400** for the whole search. Measured on a real
-boot, with the scoped query as the control:
-
-```
-GET /api/v1/search?q=acme → 400 INVALID_FILTER
-GET /api/v1/search?q=acme&objects=showcase_account → 200, 1 hit
-```
-
-Nothing in the console was affected, because the console always scopes its
-queries with `objects=`. An unscoped search is the obvious first call for a
-direct API consumer, so the defect was reachable by every one of them and by no
-console user.
-
-**The mechanism, and why it is a producer bug rather than a search bug.** The
-hidden `__search` companion column is not metadata — it is a real column the
-platform promises to build: the SchemaRegistry declares it at object compile
-time and the driver's `syncSchema` materializes it as an additive migration
-(ADR-0045). On a **federated** object (ADR-0015) that promise cannot be kept.
-The remote database owns the schema, DDL is forbidden, and the schema-sync seam
-skips those objects outright. The declaration went on anyway, so the object
-carried a field with no column — and `expandSearchToFilter`, which keys the
-companion clause on the **declared** field, ORed `{ __search: { $contains: term } }`
-into every `$search` against it. The backend then refused a statement it could
-not compile, correctly (#8790): the predicate really could not run. From the
-server log, verbatim:
-
-```
-select * from `customers` where ((lower(`name`) GLOB lower('*acme*')) or …
- or (`__search` GLOB '*acme*')) limit 5 - no such column: __search
-```
-
-Every source-column clause was fine; only the companion named a column that does
-not exist. The unscoped call is the one that sweeps every registered object, so
-it is the only global-search call that included a federated object — which is
-why scoping hid it.
-
-**The fix** is one gate at the provisioning seam: an object carrying an
-`external` binding gets no companion declaration. The predicate is
-`external != null`, deliberately the **same** expression the schema-sync seam
-already tests rather than a second question about the same fact, so the two ends
-agree by construction — every object the sync seam declines to build a column
-for is exactly an object the provisioning seam declines to declare one on.
-(Asking the datasource's `schemaMode` here instead would be a second
-implementation of one rule, and the SchemaRegistry holds no datasource
-definitions at all, so that drift would be structural rather than merely
-possible.)
-
-**Scope of the behaviour change**, all of it a restoration:
-
-- unscoped `GET /api/v1/search` returns results instead of 400;
-- `?search=` on a federated object's own list endpoint stops refusing — the same
- defect, on a call that never involved global search, and the reason the fix
- lands at the declaration rather than in the global-search sweep;
-- federated objects are searched through their source columns, as they were
- before pinyin recall existed;
-- pinyin recall is **unchanged** wherever the column is really built, and a
- federated object whose remote table genuinely has a `__search` column keeps
- its recall: the author declares that column as an ordinary field and
- provisioning returns early on an already-present entry.
-
-`/meta` for a federated object no longer advertises a `__search` field it could
-never serve.
diff --git a/.changeset/upsert-id-insert-only.md b/.changeset/upsert-id-insert-only.md
deleted file mode 100644
index 353494f711..0000000000
--- a/.changeset/upsert-id-insert-only.md
+++ /dev/null
@@ -1,52 +0,0 @@
----
-"@objectstack/driver-sql": patch
----
-
-fix(driver-sql): a merge-path upsert stops rewriting the row's primary key (#8622)
-
-
-
-`upsert(data, conflictKeys)` on a **business key** — the ordinary way to ingest
-external data — silently replaced the `id` of the row it merged into. Every
-relationship, audit record, external id mapping and client-held reference
-pointing at that row was left dangling, with no error raised on any dialect.
-
-Measured on a properly BACKED conflict target (`email` declared `unique: true`),
-so this was the supported path, not an error path:
-
-```
-upsert({ email: 'x@b.com', title: 'first' }, ['email'])
-upsert({ email: 'x@b.com', title: 'second' }, ['email'])
-
-[sqlite] before=[{id:'yMh3oywrp0Z6p-oJ', title:'first'}]
- after =[{id:'d8T8rUlTxlRlaUhN', title:'second'}] idPreserved=false
-[pg] before=[{id:'T3AlYiyDi5buzGvW', title:'first'}]
- after =[{id:'TvbCTa5mydWPYP76', title:'second'}] idPreserved=false
-```
-
-One row throughout, as intended — with a different primary key. `upsert` mints a
-nanoid for any call that supplies none, and `id` travelled in the merge set, so
-`… on conflict ("email") do update set …, "id" = excluded."id"` wrote the
-**losing** insert's fresh id over the winning row's. On the default `['id']`
-conflict target that clause is a no-op (both sides hold the same value), which is
-exactly why it stayed invisible for so long.
-
-`id` is now insert-only on the merge path, joining `created_at` and the
-`auto_number` columns (#7011) in `insertOnlyUpsertColumns` — the same exclusion
-argument at its strongest instance, since the primary key *is* the platform's row
-identity. It is resolved through `remoteColumn`, because a federated object can
-bind `id` to a differently-named physical column (ADR-0015 §18) and a literal
-`'id'` would filter nothing there.
-
-**The accept set is unchanged**: the same calls still succeed, still merge, and
-still advance `updated_at` and every other mergeable column — the merge simply
-stops rewriting row identity. Re-keying a row deliberately is still `update()`'s
-job, which writes exactly the columns it is handed.
-
-Measured on SQLite and live PostgreSQL 16.13. Live MySQL 8.0.46 measured the same
-rewrite in #8592 and its characterization pin is rewritten here to assert
-preservation; that cell had no server available in this container and runs first
-in CI's `Temporal Conformance (live PG + MySQL)` job.
diff --git a/.changeset/url-userinfo-username-accessor.md b/.changeset/url-userinfo-username-accessor.md
deleted file mode 100644
index 46690f047c..0000000000
--- a/.changeset/url-userinfo-username-accessor.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-"@objectstack/spec": minor
----
-
-feat(spec): export `urlUserinfoUsername` — the username half of the shared URL userinfo grammar (#8876)
-
-`@objectstack/spec/data` owns the DSN userinfo grammar (`urlUserinfoPassword` /
-`redactUrlPassword`, #8082/#8300) but exported only its password half. The
-mongo DSN arm (#8696) must inject a bound `external.credentialsRef` secret via
-`MongoClient`'s `auth` option, which requires the username the URL already
-names — and reading it needs this grammar, because `new URL()` throws
-`ERR_INVALID_URL` on the multi-host DSN form `MongoConfigSchema` documents
-(`mongodb://app@h1:27017,h2:27017/app`, measured). A local copy in
-`service-datasource` is the shape the #8082 single-parse ruling refuses by
-name.
-
-**Additive only.** The new accessor shares the password half's boundary parse
-by construction (both now call one internal RFC-3986 userinfo parse), returns
-the RAW component (percent-encoding preserved, decoding stays with the
-caller), answers `''` for an empty username inside present userinfo and
-`undefined` when the string carries no userinfo at all, and still parses the
-publish-refused `user:password@` shape correctly — stored legacy rows carry
-it, and #8155's migration path must judge exactly those rows. No Zod schema
-changes: every input that validated before validates identically after; the
-read-path redaction alignment pin now covers the username half too (redaction
-preserves the username byte-for-byte).
diff --git a/.changeset/value-bearing-cut-template-head.md b/.changeset/value-bearing-cut-template-head.md
deleted file mode 100644
index 36dde6f47e..0000000000
--- a/.changeset/value-bearing-cut-template-head.md
+++ /dev/null
@@ -1,55 +0,0 @@
----
-"@objectstack/objectql": patch
-"@objectstack/driver-sql": patch
----
-
-fix(objectql): a caller value containing " - " no longer eats the diagnostic's template head, and no longer leaves its own suffix in the log (#9275)
-
-`redactStatementFromMessage` cuts the bound statement off a driver error at the
-**last** ` - `, because a bound value may itself contain that separator and
-cutting at the first would leave a fragment of the value standing.
-
-When the value the DATABASE inlines into its own diagnostic also contains ` - `,
-that reasoning inverts: the last separator lands **inside the diagnostic's
-value**, so the cut discards the template head — the half that could not leak —
-and keeps a suffix of the caller's data, which is the half that does. Re-measured
-at HEAD on live PostgreSQL 16.13 with the canary
-`SENSITIVE-CANARY-9275 - 2026 - Q3`:
-
-```
-raised: insert into "t" ("age") values ($1)
- - invalid input syntax for type integer: "SENSITIVE-CANARY-9275 - 2026 - Q3"
-logged: Q3" [statement and bound values redacted]
-```
-
-`Q3` is the caller's data, at ERROR level, which is what this neighbourhood
-exists to prevent. Families with a right anchor (`for key …`,
-`for column … at row N`) already recovered through their `tail` pattern; the ones
-whose value runs to end of message had nothing to recover from.
-
-**The cut is now template-aware.** When a separator in the message stands
-immediately before a diagnostic head this file has measured, that separator is
-the true cut point whatever its position: the head survives and the value after
-it — separator and all — is dropped whole by the template that owns it. After
-the fix the same error logs
-`invalid input syntax for type integer: [value redacted] [statement and bound
-values redacted]`, so the operator keeps strictly more diagnostic than before.
-
-**Three families, not the two the card named.** `pg 22003` was left without a
-head-gone recovery on the reasoning that an out-of-range value is a number and a
-number cannot contain ` - `. Measured through the driver's own bind path, that is
-false — Postgres detects the overflow while scanning digits, *before* it rejects
-the trailing junk, so it echoes the caller's whole string:
-`insert({ age: '99999999999 - 2026 - Q3' })` logged `Q3` too. It keeps its right
-anchor, so it takes the #8823 anchor recovery rather than the new cut.
-
-The trade this takes deliberately, and its bound: matching a template before the
-cut lets a hostile value steer where the cut lands. That steering is bounded to
-**over-redaction, never exposure** — a template may declare a head only if its
-value runs to end of message, so a cut landing inside a statement is swallowed
-whole by that template; and the **last** matching head wins, so a value that
-mimics a head is cut at the mimic and cannot survive behind its own decoy. What a
-crafted value can do is suppress a real diagnostic; that cost is asserted by its
-own case rather than left to be discovered. The six identifier-bearing families
-the live probe pins are untouched — over-matching deletes the diagnostic an
-operator came for, and remains the expensive direction.
diff --git a/.changeset/value-bearing-diagnostic-probe.md b/.changeset/value-bearing-diagnostic-probe.md
deleted file mode 100644
index 0598b4e3ad..0000000000
--- a/.changeset/value-bearing-diagnostic-probe.md
+++ /dev/null
@@ -1,68 +0,0 @@
----
-"@objectstack/objectql": patch
-"@objectstack/driver-sql": patch
----
-
-fix(objectql): stop logging the caller's value for four MORE diagnostic families — measured off live MySQL 8.0 / PostgreSQL 16, not read off a manual (#9160)
-
-#8823 established that a database's diagnostic does not always name only
-IDENTIFIERS: MySQL's `ER_DUP_ENTRY` inlines the conflicting VALUE, and
-`redactStatementFromMessage` redacts that one slot while keeping the index name
-an operator needs.
-
-The list it introduced had **exactly one entry and no way to notice a second was
-missing**. Nothing measured whether a diagnostic a driver produced carried a
-value; the single entry got there because a human read one template closely, and
-the standing rule (`packages/types/src/unique-violation.ts`) — a dialect's
-spelling goes in once measured off a thrown error, never from a reading of the
-manual — correctly prevented the list from growing on a guess.
-
-**The instrument now exists.** `sql-driver-diagnostic-value-probe.test.ts` plants
-a canary, raises each candidate family through the driver's own bind path against
-the live MySQL 8.0 / PostgreSQL 16 services the `Temporal Conformance (live PG +
-MySQL)` job already stands up, and asserts of every family — value-bearing or not
-— **where the canary lands**: `error.message` (which `ObjectLogger.write`
-serializes, so an exposure) or `error.detail` (which it does not). A family that
-starts inlining a value it did not inline before is now a named red naming the
-file to edit, instead of a silent leak.
-
-Measured with a positive control first (`ER_DUP_ENTRY`, the known-value-bearing
-neighbour, reproduced verbatim — without it a zero elsewhere would be
-uninterpretable):
-
-| dialect | family | diagnostic, verbatim | verdict |
-|:--|:--|:--|:--|
-| mysql | 1062 | `Duplicate entry 'CANARY' for key 'probe.uq'` | value on `message` (already encoded) |
-| mysql | 1366 | `Incorrect integer value: 'CANARY' for column 'age' at row 1` | **value on `message`** |
-| mysql | 1292 | `Incorrect datetime value: 'CANARY' for column 'when_at' at row 1` | **value on `message`** |
-| mysql | 1264 | `Out of range value for column 'age' at row 1` | identifier only |
-| mysql | 1406 | `Data too long for column 'label' at row 1` | identifier only |
-| mysql | 1054 | `Unknown column 'zzz…' in 'field list'` | identifier only |
-| pg | 22P02 | `invalid input syntax for type integer: "CANARY"` | **value on `message`** |
-| pg | 22007 | `invalid input syntax for type timestamp with time zone: "CANARY"` | **value on `message`** |
-| pg | 22003 | `value "99999999999" is out of range for type integer` | **value on `message`** |
-| pg | 23505 | `duplicate key value violates unique constraint "…"` | value on `detail` only |
-| pg | 23502 | `null value in column "id" … violates not-null constraint` | value on `detail` only |
-| pg | 22001 | `value too long for type character varying(20)` | identifier only |
-
-Both families the card named as candidates **are** value-bearing, and the
-Postgres one is the sharper result: #8823 recorded that Postgres escapes the
-unique-violation leak only because its value sits on `error.detail`, a field the
-logger never serializes — *"coincidence, not a defence"*. `22P02` / `22007` /
-`22003` put the caller's value on **`error.message`**, the field that IS
-serialized, so the coincidence does not cover them.
-
-The one-off regex pair is now an enumerable `VALUE_BEARING_TEMPLATES` table, one
-row per measured family, each citing the live server that produced it. Every
-identifier-bearing tail is still kept whole — over-matching deletes the
-diagnostic an operator came for, which is the expensive direction #8682 paid to
-avoid, and the six identifier-only families above are pinned against exactly that
-regression.
-
-**Known residue, measured and deliberately not closed here:** when the caller's
-value itself contains ` - `, the statement cut lands inside it and eats the
-template head. Families with a right anchor (`for key …`, `for column … at row
-N`) recover; the two whose value runs to end of message (pg 22P02/22007, mysql
-1292's `Truncated incorrect …` spelling) have no anchor and leave a suffix
-standing. Closing that requires the cut itself to become template-aware — a
-change to #8682's contract, filed rather than decided.
diff --git a/.changeset/webhook-headers-secret-shape-gate.md b/.changeset/webhook-headers-secret-shape-gate.md
deleted file mode 100644
index e9fe381115..0000000000
--- a/.changeset/webhook-headers-secret-shape-gate.md
+++ /dev/null
@@ -1,75 +0,0 @@
----
-"@objectstack/plugin-webhooks": patch
----
-
-fix(webhooks): refuse a malformed `sys_webhook.headers_secret` at the write door instead of at the next delivery (#8566)
-
-
-
-`sys_webhook.headers_secret` is a `Field.secret()` whose plaintext is **not** an
-opaque blob: it is a serialized header map with a required shape — a flat JSON
-object of string values — and `parseStoredHeaders` is its only reader. Nothing
-validated that shape on the way in. The ordinary data API accepted any string,
-encrypted it like any other secret, minted a real `sys_secret` row, and left the
-column holding a perfectly valid `secret:` ref that read back as the mask with
-`active: true`.
-
-Measured on a real engine through `engine.update()` — the ordinary data API, no
-privileged access — every one of these was **accepted** and is a value the
-plugin can never use: `{}`, `[]`, `{"X-Count": 5}`, a nested object, and
-`{X-Team: crm}` (a typo). The field is directly admin-authorable and its own
-description instructs the author to type a JSON object into it, which makes a
-typo the *expected* failure rather than an exotic one.
-
-**This is not an exposure fix and must not be read as one.** #8558/#8565 already
-closed the consumer half: a webhook whose stored header map does not come back
-as a flat string map parks the subscription and reports at `error`, rather than
-delivering header-less with a valid signature. Nothing leaks, and nothing is
-silently lost today. What this changes is **when the author finds out** — at the
-write door where they typed it, instead of at the next matching record change,
-an unbounded time later and in a different surface.
-
-**What is refused:** a `headers_secret` plaintext that does not parse back as a
-flat JSON object of string values with at least one entry, with a located
-ADR-0112 `VALIDATION_ERROR` / 400 naming `sys_webhook.headers_secret`, quoting
-the shape the field's own description asks for, and diagnosing the specific
-spelling (invalid JSON / an array / an empty object / which key's value is not a
-string). ⛔ The message never echoes the rejected value — this column carries
-credentials, and quoting the input would print an `Authorization: Bearer …` into
-logs and error bodies, re-opening in the diagnostic exactly the exposure #7986
-moved this field onto the encrypted channel to close. It names header *keys* and
-value *types* only.
-
-**What stays accepted, byte for byte:** every valid flat string map (as JSON
-text, or as an authored object the engine serializes into the same form); `null`
-to clear; an omitted key to leave the stored value unchanged; and an **echoed
-read-mask**, so the ordinary Setup-form round-trip (GET a row, edit an unrelated
-field, PATCH it back) is untouched. `""` is deliberately passed through to
-#8559's `EmptyCredentialWriteError` rather than re-refused here — one door, one
-owner, one message.
-
-**Where it runs, and why that is the whole mechanism:** a `beforeInsert` /
-`beforeUpdate` hook on `sys_webhook`, bound by `WebhookOutboxPlugin` before its
-first seeded write. It has to run *before* the engine's `encryptSecretFields` —
-one step later the plaintext is gone and the column holds an opaque ref, so a
-validator behind it would have nothing left to validate. The suite measures that
-ordering rather than asserting it: every refusal pins that **no `sys_secret`
-cipher row was minted**, which is only true if the gate ran first.
-
-A hook rather than checks on the plugin's own write paths
-(`bootstrapDeclaredWebhooks` / `headersPatch` / the migration sweep), because a
-direct `PATCH /api/v1/data/sys_webhook` goes through none of them and that is
-the measured trigger. Those paths inherit the validation through the hook and
-deliberately carry no second check.
-
-A general `secret`-channel plaintext validator — letting any `secret`-typed
-field declare its own plaintext shape — is the principled generalization and is
-recorded as the **promotion path**, not built here: it becomes the shape the
-moment a second shaped-plaintext `secret` field exists (maintainer ruling
-2026-08-13; one consumer does not justify a general capability).
diff --git a/.changeset/wise-pugs-attend.md b/.changeset/wise-pugs-attend.md
deleted file mode 100644
index 1df9ab9310..0000000000
--- a/.changeset/wise-pugs-attend.md
+++ /dev/null
@@ -1,34 +0,0 @@
----
-"@objectstack/plugin-audit": minor
----
-
-Record-view auditing: `sys_audit_log` can now answer "who viewed which record"
-
-`sys_audit_log` covered writes only, so the question every regulated-industry
-security review opens with — *who viewed this customer record, and when?* — had
-no answer short of custom work. The ledger now has a `read` action, its writer,
-and the `record_views` list view that surfaces it.
-
-Scope is deliberately narrow (maintainer ruling 2026-08-16):
-
-- **Record-detail views only.** A read qualifies when it materialized one record
- and its predicate pinned the primary key — the shape `GET /data/:object/:id`
- produces. List and search reads are not audited.
-- **Per-object opt-in, closed.** Nothing is recorded until a deployment names the
- objects: `new AuditPlugin({ readAudit: { objects: ['contact', 'account'] } })`.
- There is no global switch and no exception list, and an empty opt-in registers
- no hook at all, so the default posture costs a read nothing.
-- **Batched off the request path.** The hook buffers and returns; rows are
- persisted on a later tick, size- or timer-triggered, and flushed on shutdown.
- Each row keeps the instant the record was VIEWED, not the instant its batch
- drained.
-
-The row records who, what and when — never field values. Read auditing runs
-inside the security middleware, ahead of its field masking, so the record it sees
-is pre-mask; copying values in would mint a plaintext copy of exactly what
-field-level security withholds, in the table compliance staff are granted broad
-access to.
-
-Two boundaries are declared rather than left to be discovered: a system-elevated
-read (`api.sudo()`, formula recomputes, roll-ups) writes no row, and neither does
-a read with no principal to name.
diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx
index 1993d7b148..08994aedcb 100644
--- a/content/docs/deployment/self-hosting.mdx
+++ b/content/docs/deployment/self-hosting.mdx
@@ -73,7 +73,7 @@ docker run -p 8080:8080 \
-e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \
-e OS_AUTH_SECRET \
-e OS_SECRET_KEY \
- ghcr.io/objectstack-ai/objectstack:17.0.0
+ ghcr.io/objectstack-ai/objectstack:17.1.0
```
(`OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come
@@ -91,7 +91,7 @@ docker run -p 8080:8080 \
-e OS_ARTIFACT_URL="https://releases.example.com/hotcrm-2.2.2.json#sha256=<64 hex chars>" \
-e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \
-e OS_AUTH_SECRET -e OS_SECRET_KEY \
- ghcr.io/objectstack-ai/objectstack:17.0.0
+ ghcr.io/objectstack-ai/objectstack:17.1.0
```
Both schemes work: `https://…` is fetched at boot, `file:///…` is read directly
@@ -142,7 +142,7 @@ COPY . .
RUN npx os build # → dist/objectstack.json
# ── Runtime: the official ObjectStack runtime image ──────────────────
-FROM ghcr.io/objectstack-ai/objectstack:17.0.0
+FROM ghcr.io/objectstack-ai/objectstack:17.1.0
COPY --from=build --chown=node:node /app/dist/objectstack.json /srv/app/objectstack.json
```
@@ -160,7 +160,7 @@ image)? The official image is nothing more than:
```dockerfile title="Dockerfile (self-built runtime, equivalent)"
FROM node:22-slim
-RUN npm install -g @objectstack/cli@17.0.0
+RUN npm install -g @objectstack/cli@17.1.0
WORKDIR /srv/app
RUN chown node:node /srv/app
diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx
index 579b54fdd0..abf889c3ef 100644
--- a/content/docs/upgrading.mdx
+++ b/content/docs/upgrading.mdx
@@ -38,7 +38,7 @@ version in production** and move it deliberately:
```bash
# docker-compose.yml, or your orchestrator's manifest
-image: ghcr.io/objectstack-ai/objectstack:17.0.0
+image: ghcr.io/objectstack-ai/objectstack:17.1.0
```
On a host running the artifact directly under systemd, the same move is a file
diff --git a/docker/README.md b/docker/README.md
index ed61a66a9b..f1855f4de2 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -29,7 +29,7 @@ Multi-arch: `linux/amd64` + `linux/arm64`.
[Self-Hosted Deployment](https://objectstack.ai/docs/deployment/self-hosting)):
```dockerfile
-FROM ghcr.io/objectstack-ai/objectstack:17.0.0
+FROM ghcr.io/objectstack-ai/objectstack:17.1.0
COPY --chown=node:node dist/objectstack.json /srv/app/objectstack.json
```
@@ -40,7 +40,7 @@ docker run -p 8080:8080 \
-v "$PWD/dist/objectstack.json:/srv/app/objectstack.json:ro" \
-e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \
-e OS_AUTH_SECRET -e OS_SECRET_KEY \
- ghcr.io/objectstack-ai/objectstack:17.0.0
+ ghcr.io/objectstack-ai/objectstack:17.1.0
```
`OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come
@@ -62,5 +62,5 @@ reverse-proxy / multi-node guidance:
## Local build of this image
```bash
-docker build -t objectstack:dev --build-arg OS_CLI_VERSION=17.0.0 docker/
+docker build -t objectstack:dev --build-arg OS_CLI_VERSION=17.1.0 docker/
```
diff --git a/examples/app-crm/CHANGELOG.md b/examples/app-crm/CHANGELOG.md
index bdd2ec03a7..ab4a8da5c5 100644
--- a/examples/app-crm/CHANGELOG.md
+++ b/examples/app-crm/CHANGELOG.md
@@ -1,5 +1,111 @@
# @objectstack/example-crm
+## 4.0.93
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [ca2e020]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [e43d63a]
+- Updated dependencies [e374b4d]
+- Updated dependencies [a433122]
+- Updated dependencies [bc6434b]
+- Updated dependencies [96f397a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [48032c9]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [6a51704]
+- Updated dependencies [420804d]
+- Updated dependencies [c8e85fc]
+- Updated dependencies [3d61924]
+- Updated dependencies [5244fd7]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [b2789ad]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [6aceca9]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [19539b4]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [20067c5]
+- Updated dependencies [e783e16]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [4fc4a3c]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [7fc01db]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [c86799f]
+- Updated dependencies [5989b0d]
+- Updated dependencies [19db5fa]
+- Updated dependencies [2b9d33a]
+- Updated dependencies [ad217b1]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/runtime@17.1.0
+
## 4.0.92
### Patch Changes
diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json
index ae1f24534d..c7e63345da 100644
--- a/examples/app-crm/package.json
+++ b/examples/app-crm/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/example-crm",
- "version": "4.0.92",
+ "version": "4.0.93",
"description": "Minimal CRM example \u2014 a smoke-test workspace that exercises the metadata loading pipeline (objects \u2192 views \u2192 app \u2192 dashboard \u2192 hook \u2192 flow \u2192 seed). For a full-featured enterprise CRM see https://github.com/objectstack-ai/hotcrm.",
"license": "Apache-2.0",
"private": true,
diff --git a/examples/app-showcase/CHANGELOG.md b/examples/app-showcase/CHANGELOG.md
index 5b0c85ac2e..60d89baa8c 100644
--- a/examples/app-showcase/CHANGELOG.md
+++ b/examples/app-showcase/CHANGELOG.md
@@ -1,5 +1,238 @@
# @objectstack/example-showcase
+## 0.3.15
+
+### Patch Changes
+
+- 06f9848: Land the showcase seed fixtures the platform checklist could not run without (#9308)
+
+ Three capabilities the platform ships had no fixture anywhere in the reference app, so the
+ checklist items covering them were not failing — they were unrunnable. Each is closed here
+ with the smallest stock addition that makes it observable, and with the negative control
+ left intact.
+
+ **A second, actually loginable member.** The demo personas (Mei Phone the submitter, Ada
+ Auditor the sole `auditor`) have existed as `sys_user` rows since #3409/#3411, and neither
+ could sign in — so every item needing two acting identities was stuck: per-group 会签 needs
+ the two groups decided by two different people, submitter-side viewer gating needs the
+ submitter looking at their own request, and an out-of-office delegation is only falsifiable
+ when the delegate holds a separate token. The non-obvious half is why a password hash was
+ never enough: better-auth 1.7 keys accounts on `(issuer, providerAccountId)`, so a
+ credential row carrying any other issuer is invisible to sign-in, which then fails
+ `INVALID_EMAIL_OR_PASSWORD` behind a "User not found" warn pointing at the user row rather
+ than at the account. `seed-approval-demo.ts` now provisions the credential account through
+ better-auth's own `$context` — its hasher, its `internalAdapter.createAccount` — and READS
+ the issuer off the dev admin's own credential row instead of re-spelling a constant
+ `plugin-auth` owns, so the two cannot drift. Dev-only by construction: the bootstrap runs
+ only where the dev admin exists, and that admin is hard-gated on `NODE_ENV=development`.
+
+ **An object that opts into `publicSharing`.** No stock object declared it, so
+ `POST /share-links` answered 422 `SHARING_NOT_ENABLED` for every showcase object and the
+ whole downstream half of link sharing — resolve, redaction, the audience and password
+ gates, fail-closed revoke — was unreachable. `showcase_client_brief` opts in with
+ `redactFields`, an expiry cap and an `eligibility` predicate, and the seed carries both a
+ `published` brief (mint-eligible) and a `draft` one (refused `RECORD_NOT_ELIGIBLE`) so the
+ predicate is falsifiable and not merely satisfied. Every other object still declines the
+ opt-in, which is what keeps the per-object 422 a real control.
+
+ **A `readable: false` FLS grant.** The app governed the three `showcase_project` budget
+ figures with `readable: true, editable: false` — the WRITE half of field-level security —
+ and authored no read-withheld grant at all, leaving `plugin-security`'s field masker with
+ no stock fixture. `showcase_client_liaison` is that grant, on the same three fields, so the
+ two sets read side by side as the two halves of one mechanism. All three figures move
+ together because `budget_remaining` is a formula over `budget - spent` and masking one
+ leaks it back through arithmetic.
+
+ Downstream reconciliations, each deliberate: `access-matrix.json` gains two rows and moves
+ none; the persona × CRUD sweep's census follows the matrix (50/50 → 54/54, arithmetic
+ recorded at the assertion) and its fixture maps learn the new object; the position count
+ pin follows the new position. The five checklist items whose `knownGaps` this closes are
+ revised in the same change — gap text kept, marked closed-by-fixture, `revision` bumped,
+ `history` appended.
+- b0fa4fc: Guard the showcase's authored action predicates against the sparse action face (#8990)
+
+ Every record-scoped `visible` / `disabled` predicate in `app-showcase` now carries the
+ `has()` guard the sparse action face requires, closing the remainder of #8990 in this
+ repo. A row action's predicate binds a LIST ROW carrying only the view's `$select`
+ projection, and CEL aborts with `No such key` on a column that row never projected —
+ fail-closed, so the button silently is not offered.
+
+ Measured against the running app's own payloads: 40 of the 53 predicates in
+ `predicate-matrix.action.ts` aborted on a default-list row before this change and 0 do
+ after, while every verdict on a record-detail binding is unchanged — the Full-vs-Minimal
+ contrast the fixture exists to demonstrate is preserved exactly.
+
+ The guard is minimal per predicate rather than blanket: `has()` alone where the read is
+ only compared by `==` / `!=` (CEL compares heterogeneously and answers `false` rather
+ than faulting), the full `has(x) && x != null` conjunction only where an operand can
+ fault — traversal, method call, ordering, arithmetic, `in`, or a bare `!`.
+
+ The teaching surfaces move with the code, since they quote it: `content/docs/ui/actions.mdx`
+ (whose `visible: '!record.done'` was the exact negation shape that faults on a NULL
+ column), `quick-start.mdx` and `build-with-claude-code.mdx`.
+- 4012a70: Retire the showcase sharing rules no gate could consult; re-home the position/compound demo (#9237)
+
+ Booting `examples/app-showcase` logged two WARNs per boot — `SharingServicePlugin: boot
+ rule backfill failed for rule` for `share_open_tasks_with_manager` and
+ `share_red_projects_with_execs`. Both sat on objects declaring
+ `sharingModel: 'public_read_write'`, where sharing has nothing left to widen, so
+ `assertNotInertGrant` (ADR-0111 D7) refused every grant they reconciled. A third rule,
+ `share_high_value_red_projects_with_managers`, was in exactly the same state and produced
+ no diagnostic at all: its compound condition matched no seeded row, so `reconcile` never
+ reached `grant` and never threw.
+
+ `showcase_project` and `showcase_task` are `public_read_write` by deliberate ADR-0090 D1
+ declaration and that OWD is load-bearing beyond the security demo, so no rule can ever take
+ effect there. ADR-0049 enforce-or-remove leaves one honest move, and all three are removed
+ rather than re-homed onto another public object — the shape the previous repair took, which
+ moved the inertness instead of removing it.
+
+ The two capabilities they carried are kept: a `position` recipient and a compound CEL
+ condition (ADR-0058 D3) now live on `share_key_account_qualified_contacts_with_managers`,
+ targeting `showcase_contact` (OWD `private`, and the `showcase_manager` set grants it
+ `allowRead` — the object-level bit a share row still needs). The seeded contacts
+ demonstrate the AND in both directions: rows satisfying either clause alone are not
+ shared.
+
+ `inert-wirings.test.ts` gains the guard that fails the build on the next such declaration,
+ in both of its shapes — a rule anchored where the OWD leaves nothing to widen, and a rule
+ whose audience holds no `allowRead` on the object it shares.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [ca2e020]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [e43d63a]
+- Updated dependencies [e374b4d]
+- Updated dependencies [a433122]
+- Updated dependencies [bc6434b]
+- Updated dependencies [96f397a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [48032c9]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [8bbf459]
+- Updated dependencies [2277443]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [5c38492]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [3508678]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [6a51704]
+- Updated dependencies [2c570f3]
+- Updated dependencies [7337f30]
+- Updated dependencies [420804d]
+- Updated dependencies [c8e85fc]
+- Updated dependencies [3d61924]
+- Updated dependencies [5244fd7]
+- Updated dependencies [cbf4b40]
+- Updated dependencies [9c4d096]
+- Updated dependencies [86431f7]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [b2789ad]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [6aceca9]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [19539b4]
+- Updated dependencies [e0695b5]
+- Updated dependencies [01074e5]
+- Updated dependencies [7c3a7eb]
+- Updated dependencies [a9df51c]
+- Updated dependencies [11b779e]
+- Updated dependencies [ab8b10f]
+- Updated dependencies [739fe5b]
+- Updated dependencies [20067c5]
+- Updated dependencies [e783e16]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [4fc4a3c]
+- Updated dependencies [90a12fb]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [72050cc]
+- Updated dependencies [d70428a]
+- Updated dependencies [9a56784]
+- Updated dependencies [c8806ae]
+- Updated dependencies [bb96297]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [3b3f67d]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [d2e6b1d]
+- Updated dependencies [0961065]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [05864fb]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [7fc01db]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [c86799f]
+- Updated dependencies [990a893]
+- Updated dependencies [5989b0d]
+- Updated dependencies [19db5fa]
+- Updated dependencies [2b9d33a]
+- Updated dependencies [ad217b1]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [a4acb8d]
+- Updated dependencies [d634e66]
+- Updated dependencies [682b86b]
+- Updated dependencies [6a1b45e]
+ - @objectstack/spec@17.1.0
+ - @objectstack/runtime@17.1.0
+ - @objectstack/driver-sql@17.1.0
+ - @objectstack/cloud-connection@17.1.0
+ - @objectstack/service-datasource@17.1.0
+ - @objectstack/connector-mcp@17.1.0
+ - @objectstack/connector-openapi@17.1.0
+ - @objectstack/connector-rest@17.1.0
+ - @objectstack/connector-slack@17.1.0
+
## 0.3.14
### Patch Changes
diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json
index 3f8d191cf2..4586704aa8 100644
--- a/examples/app-showcase/package.json
+++ b/examples/app-showcase/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/example-showcase",
- "version": "0.3.14",
+ "version": "0.3.15",
"description": "Kitchen-sink showcase workspace — exercises every metadata type, every view type, every chart type, and the major end-to-end capability chains (security, automation, analytics). Built for demonstration, debugging, and coverage-driven verification.",
"license": "Apache-2.0",
"private": true,
diff --git a/examples/app-todo/CHANGELOG.md b/examples/app-todo/CHANGELOG.md
index 81824c0803..65ae5cc858 100644
--- a/examples/app-todo/CHANGELOG.md
+++ b/examples/app-todo/CHANGELOG.md
@@ -1,5 +1,146 @@
# @objectstack/example-todo
+## 4.0.93
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [ca2e020]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [e43d63a]
+- Updated dependencies [e374b4d]
+- Updated dependencies [a433122]
+- Updated dependencies [bc6434b]
+- Updated dependencies [96f397a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [48032c9]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [a751f7d]
+- Updated dependencies [eccb8b2]
+- Updated dependencies [650cd3d]
+- Updated dependencies [b735507]
+- Updated dependencies [91c6c28]
+- Updated dependencies [75b7c24]
+- Updated dependencies [caaae2c]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [6a51704]
+- Updated dependencies [7337f30]
+- Updated dependencies [420804d]
+- Updated dependencies [c8e85fc]
+- Updated dependencies [3d61924]
+- Updated dependencies [5244fd7]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [b2789ad]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [6aceca9]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [19539b4]
+- Updated dependencies [11b779e]
+- Updated dependencies [4e71ae1]
+- Updated dependencies [739fe5b]
+- Updated dependencies [20067c5]
+- Updated dependencies [e783e16]
+- Updated dependencies [ff4ba6a]
+- Updated dependencies [f9d7acf]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [2a9752c]
+- Updated dependencies [4fc4a3c]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [4dfa369]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [5e2f594]
+- Updated dependencies [e2899f6]
+- Updated dependencies [855591f]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [0425db9]
+- Updated dependencies [cd455c8]
+- Updated dependencies [326f5de]
+- Updated dependencies [30d3752]
+- Updated dependencies [21995d7]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [6a5e6ad]
+- Updated dependencies [30b1c63]
+- Updated dependencies [7fc01db]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [c86799f]
+- Updated dependencies [5989b0d]
+- Updated dependencies [19db5fa]
+- Updated dependencies [2b9d33a]
+- Updated dependencies [ad217b1]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [b2a451f]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [7c2f386]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [8a9e7f4]
+- Updated dependencies [3d0ded8]
+- Updated dependencies [44bc51d]
+- Updated dependencies [1258dca]
+- Updated dependencies [91c4ff5]
+- Updated dependencies [d634e66]
+- Updated dependencies [682b86b]
+- Updated dependencies [6a1b45e]
+ - @objectstack/spec@17.1.0
+ - @objectstack/runtime@17.1.0
+ - @objectstack/objectql@17.1.0
+ - @objectstack/client@17.1.0
+ - @objectstack/driver-sqlite-wasm@17.1.0
+ - @objectstack/metadata@17.1.0
+ - @objectstack/mcp@17.1.0
+ - @objectstack/service-knowledge@17.1.0
+ - @objectstack/knowledge-memory@17.1.0
+
## 4.0.92
### Patch Changes
diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json
index cfd14f0acc..e2f14a1e5e 100644
--- a/examples/app-todo/package.json
+++ b/examples/app-todo/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/example-todo",
- "version": "4.0.92",
+ "version": "4.0.93",
"description": "Example Todo App using ObjectStack Protocol",
"license": "Apache-2.0",
"private": true,
diff --git a/examples/embed-objectql/CHANGELOG.md b/examples/embed-objectql/CHANGELOG.md
index d41afb8bac..e952a218dd 100644
--- a/examples/embed-objectql/CHANGELOG.md
+++ b/examples/embed-objectql/CHANGELOG.md
@@ -1,5 +1,113 @@
# @objectstack/example-embed-objectql
+## 0.0.33
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [e374b4d]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [a751f7d]
+- Updated dependencies [eccb8b2]
+- Updated dependencies [650cd3d]
+- Updated dependencies [b735507]
+- Updated dependencies [91c6c28]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [7337f30]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [19539b4]
+- Updated dependencies [11b779e]
+- Updated dependencies [4e71ae1]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [4dfa369]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [5e2f594]
+- Updated dependencies [e2899f6]
+- Updated dependencies [855591f]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [326f5de]
+- Updated dependencies [30d3752]
+- Updated dependencies [21995d7]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [6a5e6ad]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [b2a451f]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [7c2f386]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [8a9e7f4]
+- Updated dependencies [3d0ded8]
+- Updated dependencies [44bc51d]
+- Updated dependencies [1258dca]
+- Updated dependencies [91c4ff5]
+- Updated dependencies [d634e66]
+- Updated dependencies [682b86b]
+- Updated dependencies [6a1b45e]
+ - @objectstack/spec@17.1.0
+ - @objectstack/objectql@17.1.0
+ - @objectstack/driver-memory@17.1.0
+
## 0.0.32
### Patch Changes
diff --git a/examples/embed-objectql/package.json b/examples/embed-objectql/package.json
index ce6a5253ec..fa9e19db11 100644
--- a/examples/embed-objectql/package.json
+++ b/examples/embed-objectql/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/example-embed-objectql",
- "version": "0.0.32",
+ "version": "0.0.33",
"private": true,
"description": "Embed the ObjectQL engine as a plain library via @objectstack/objectql/core — no kernel, no plugins, no metadata protocol (ADR-0076).",
"type": "module",
diff --git a/packages/adapters/hono/CHANGELOG.md b/packages/adapters/hono/CHANGELOG.md
index a7596caaf2..a3fd589de3 100644
--- a/packages/adapters/hono/CHANGELOG.md
+++ b/packages/adapters/hono/CHANGELOG.md
@@ -1,5 +1,135 @@
# @objectstack/hono
+## 17.1.0
+
+### Minor Changes
+
+- 4e52147: 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.
+- 88e1bac: fix(api): the plugin-mounted Hono error paths answer the declared envelope — six refusal bodies stop speaking the pre-#3675 dialect (#9364)
+
+ Six hand-built refusal bodies on plugin-mounted Hono routes departed from
+ `BaseResponseSchema`. They were invisible to every check in the repo until
+ #9267 added the gate's third surface, which discovers these routes by parsing
+ rather than by filename. This converts the **error-path** half of what that
+ first run measured; the bare pre-auth discovery payloads it also found are a
+ separate wire ruling (#9389) and are untouched here.
+
+ **If you branch on these bodies, this is the change.** Every one of them was
+ readable only by reaching for a key the contract does not declare, so no
+ consumer that followed `ApiErrorSchema` was reading them successfully in the
+ first place — `body.error.message` read `undefined` on all six.
+
+ `@objectstack/plugin-hono-server` — the adapter's own refusals, the answer any
+ host using it as its transport gets for an unmatched request or a handler that
+ produced nothing:
+
+ | status | was | now |
+ |:--|:--|:--|
+ | 404 unmatched path | `{ error: 'Not found' }` | `{ success: false, error: { code: 'ENDPOINT_NOT_FOUND', message: 'Not found' } }` |
+ | 405 method mismatch | `{ error, code, message, method, path, allowed }` | `{ success: false, error: { code: 'METHOD_NOT_ALLOWED', message, details: { method, path, allowed } } }` |
+ | 500 handler wrote nothing | `{ error: 'No response from handler' }` | `{ success: false, error: { code: 'INTERNAL_ERROR', message: 'No response from handler' } }` |
+ | 500 fallback threw | `{ error: 'Fallback handler failed' }` | `{ success: false, error: { code: 'INTERNAL_ERROR', message: 'Fallback handler failed' } }` |
+
+ The 405 is the sharpest of the four: it already carried a real semantic code,
+ but placed it BESIDE `error` rather than inside it, so `body.error.code` read
+ `undefined` while `body.code` worked — the #7035 dialect. Its `code` **value**
+ is unchanged (`METHOD_NOT_ALLOWED`, a `StandardErrorCode` member); only its
+ position moved, along with the three context keys, which are now
+ `error.details` — the slot `ApiErrorSchema` declares for exactly that. The
+ `Allow` header is unchanged and remains the primary channel for it.
+
+ `@objectstack/hono` — the shared `errorJson` helper wrote the HTTP **status**
+ into `error.code`, so every refusal from this mount shipped `error.code: 404`
+ or `500` where `ApiErrorSchema.code` declares a closed STRING vocabulary
+ (ADR-0112 D3/D4). It now derives the standard member for the status through
+ `resolveThrownHttpError` (`@objectstack/types`) — the one rule the REST and
+ dispatcher doors already read for this question, so this third door does not
+ become a fourth dialect. A 404 from this mount now carries
+ `error.code: 'RESOURCE_NOT_FOUND'`; the numeric status stays where it is
+ authoritative, on the response line.
+
+ `@objectstack/cli` — the unbound-hostname 404 from `os serve`'s
+ `OS_ROOT_DOMAIN` guard answered
+ `{ error: 'environment_not_found', message, hostname }`: a bare-string error
+ with two stray top-level keys, and a lowercase code where error codes are
+ `SCREAMING_SNAKE`. It is now
+ `{ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message, details: { hostname } } }`.
+ The `Accept: text/html` branch still serves the styled 404 page, unchanged.
+
+ **The cross-adapter reference implementation moved with it.**
+ `@objectstack/http-conformance`'s zero-dependency `NodeHttpServer` mirrors the
+ adapter's unmatched-request bodies byte-for-byte on purpose — the whole point
+ of that package is proving the transport port is free of framework-isms, and
+ `fallback-seam.conformance.test.ts` runs the same cases against both. Leaving
+ it behind would have made "both adapters agree" false in the suite that exists
+ to assert it.
+
+ Every converted body is judged by `scripts/check-route-envelope.mjs`, whose
+ per-file counters for these three modules go to zero and are banked as
+ conformant. The literals are deliberately written INLINE at each `c.json(...)`
+ call rather than hoisted into shared constants: the gate reads the object
+ literal, and an identifier reads to it as a relayed body it must not police —
+ hoisting would have zeroed the counters by hiding the bodies from the scanner
+ instead of by conforming them.
+
+### Patch Changes
+
+- Updated dependencies [2f65b1b]
+- Updated dependencies [ca2e020]
+- Updated dependencies [e43d63a]
+- Updated dependencies [e374b4d]
+- Updated dependencies [a433122]
+- Updated dependencies [bc6434b]
+- Updated dependencies [96f397a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [48032c9]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [6a51704]
+- Updated dependencies [2d0af57]
+- Updated dependencies [7337f30]
+- Updated dependencies [c8e85fc]
+- Updated dependencies [3d61924]
+- Updated dependencies [5244fd7]
+- Updated dependencies [b2789ad]
+- Updated dependencies [27a567d]
+- Updated dependencies [6aceca9]
+- Updated dependencies [152bff8]
+- Updated dependencies [7ff3975]
+- Updated dependencies [20067c5]
+- Updated dependencies [e783e16]
+- Updated dependencies [4fc4a3c]
+- Updated dependencies [88e1bac]
+- Updated dependencies [7fc01db]
+- Updated dependencies [c86799f]
+- Updated dependencies [5989b0d]
+- Updated dependencies [19db5fa]
+- Updated dependencies [2b9d33a]
+- Updated dependencies [ad217b1]
+- Updated dependencies [593c4bf]
+- Updated dependencies [bbbfcfc]
+ - @objectstack/types@17.1.0
+ - @objectstack/runtime@17.1.0
+ - @objectstack/plugin-hono-server@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/adapters/hono/package.json b/packages/adapters/hono/package.json
index 6dcfb870e6..1890a357e7 100644
--- a/packages/adapters/hono/package.json
+++ b/packages/adapters/hono/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/hono",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
diff --git a/packages/apps/account/CHANGELOG.md b/packages/apps/account/CHANGELOG.md
index d145514efb..64ac88e904 100644
--- a/packages/apps/account/CHANGELOG.md
+++ b/packages/apps/account/CHANGELOG.md
@@ -1,5 +1,101 @@
# @objectstack/account
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [19539b4]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+
## 17.0.0
### Patch Changes
diff --git a/packages/apps/account/package.json b/packages/apps/account/package.json
index d6ae18dce0..d187c8f90b 100644
--- a/packages/apps/account/package.json
+++ b/packages/apps/account/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/account",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "ObjectStack Account — the end-user account/self-service console app, packaged as its own ObjectStack app package (ADR-0048: one app per package).",
"main": "dist/index.js",
diff --git a/packages/apps/setup/CHANGELOG.md b/packages/apps/setup/CHANGELOG.md
index 9dbe15f58c..2cf983515d 100644
--- a/packages/apps/setup/CHANGELOG.md
+++ b/packages/apps/setup/CHANGELOG.md
@@ -1,5 +1,101 @@
# @objectstack/setup
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [19539b4]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+
## 17.0.0
### Patch Changes
diff --git a/packages/apps/setup/package.json b/packages/apps/setup/package.json
index ce16732526..8c13355071 100644
--- a/packages/apps/setup/package.json
+++ b/packages/apps/setup/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/setup",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "ObjectStack Setup — the platform administration app, packaged as its own ObjectStack app package (ADR-0048: one app per package).",
"main": "dist/index.js",
diff --git a/packages/apps/studio/CHANGELOG.md b/packages/apps/studio/CHANGELOG.md
index 4f6a2026f1..a1ffeb850d 100644
--- a/packages/apps/studio/CHANGELOG.md
+++ b/packages/apps/studio/CHANGELOG.md
@@ -1,5 +1,101 @@
# @objectstack/studio
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [19539b4]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+
## 17.0.0
### Patch Changes
diff --git a/packages/apps/studio/package.json b/packages/apps/studio/package.json
index 68154af0c4..414b2f34f9 100644
--- a/packages/apps/studio/package.json
+++ b/packages/apps/studio/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/studio",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "ObjectStack Studio — the metadata builder app, packaged as its own ObjectStack app package (ADR-0048: one app per package).",
"main": "dist/index.js",
diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md
index 306afa757a..c01c8caef1 100644
--- a/packages/cli/CHANGELOG.md
+++ b/packages/cli/CHANGELOG.md
@@ -1,5 +1,845 @@
# @objectstack/cli
+## 17.1.0
+
+### Minor Changes
+
+- 14c9ad7: 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.
+- 51cd953: 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.
+- 88e1bac: fix(api): the plugin-mounted Hono error paths answer the declared envelope — six refusal bodies stop speaking the pre-#3675 dialect (#9364)
+
+ Six hand-built refusal bodies on plugin-mounted Hono routes departed from
+ `BaseResponseSchema`. They were invisible to every check in the repo until
+ #9267 added the gate's third surface, which discovers these routes by parsing
+ rather than by filename. This converts the **error-path** half of what that
+ first run measured; the bare pre-auth discovery payloads it also found are a
+ separate wire ruling (#9389) and are untouched here.
+
+ **If you branch on these bodies, this is the change.** Every one of them was
+ readable only by reaching for a key the contract does not declare, so no
+ consumer that followed `ApiErrorSchema` was reading them successfully in the
+ first place — `body.error.message` read `undefined` on all six.
+
+ `@objectstack/plugin-hono-server` — the adapter's own refusals, the answer any
+ host using it as its transport gets for an unmatched request or a handler that
+ produced nothing:
+
+ | status | was | now |
+ |:--|:--|:--|
+ | 404 unmatched path | `{ error: 'Not found' }` | `{ success: false, error: { code: 'ENDPOINT_NOT_FOUND', message: 'Not found' } }` |
+ | 405 method mismatch | `{ error, code, message, method, path, allowed }` | `{ success: false, error: { code: 'METHOD_NOT_ALLOWED', message, details: { method, path, allowed } } }` |
+ | 500 handler wrote nothing | `{ error: 'No response from handler' }` | `{ success: false, error: { code: 'INTERNAL_ERROR', message: 'No response from handler' } }` |
+ | 500 fallback threw | `{ error: 'Fallback handler failed' }` | `{ success: false, error: { code: 'INTERNAL_ERROR', message: 'Fallback handler failed' } }` |
+
+ The 405 is the sharpest of the four: it already carried a real semantic code,
+ but placed it BESIDE `error` rather than inside it, so `body.error.code` read
+ `undefined` while `body.code` worked — the #7035 dialect. Its `code` **value**
+ is unchanged (`METHOD_NOT_ALLOWED`, a `StandardErrorCode` member); only its
+ position moved, along with the three context keys, which are now
+ `error.details` — the slot `ApiErrorSchema` declares for exactly that. The
+ `Allow` header is unchanged and remains the primary channel for it.
+
+ `@objectstack/hono` — the shared `errorJson` helper wrote the HTTP **status**
+ into `error.code`, so every refusal from this mount shipped `error.code: 404`
+ or `500` where `ApiErrorSchema.code` declares a closed STRING vocabulary
+ (ADR-0112 D3/D4). It now derives the standard member for the status through
+ `resolveThrownHttpError` (`@objectstack/types`) — the one rule the REST and
+ dispatcher doors already read for this question, so this third door does not
+ become a fourth dialect. A 404 from this mount now carries
+ `error.code: 'RESOURCE_NOT_FOUND'`; the numeric status stays where it is
+ authoritative, on the response line.
+
+ `@objectstack/cli` — the unbound-hostname 404 from `os serve`'s
+ `OS_ROOT_DOMAIN` guard answered
+ `{ error: 'environment_not_found', message, hostname }`: a bare-string error
+ with two stray top-level keys, and a lowercase code where error codes are
+ `SCREAMING_SNAKE`. It is now
+ `{ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message, details: { hostname } } }`.
+ The `Accept: text/html` branch still serves the styled 404 page, unchanged.
+
+ **The cross-adapter reference implementation moved with it.**
+ `@objectstack/http-conformance`'s zero-dependency `NodeHttpServer` mirrors the
+ adapter's unmatched-request bodies byte-for-byte on purpose — the whole point
+ of that package is proving the transport port is free of framework-isms, and
+ `fallback-seam.conformance.test.ts` runs the same cases against both. Leaving
+ it behind would have made "both adapters agree" false in the suite that exists
+ to assert it.
+
+ Every converted body is judged by `scripts/check-route-envelope.mjs`, whose
+ per-file counters for these three modules go to zero and are banked as
+ conformant. The literals are deliberately written INLINE at each `c.json(...)`
+ call rather than hoisted into shared constants: the gate reads the object
+ literal, and an identifier reads to it as a relayed body it must not police —
+ hoisting would have zeroed the counters by hiding the bodies from the scanner
+ instead of by conforming them.
+
+### Patch Changes
+
+- e374b4d: 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.
+- 189a732: `serve`: the cloud-connected marketplace arm now leaves a host config's own marketplace and cloud plugins alone.
+
+ `objectstack serve` auto-wires `MarketplaceProxyPlugin`, `MarketplaceInstallLocalPlugin`, the same-origin cloud-connection surface and `RuntimeConfigPlugin` whenever a cloud URL resolves. Each of those four mounts is now guarded on whether the loaded host config already wired that surface — the same presence check the offline arm has carried since the install-local fix — so CLI auto-wiring is a fallback for hosts that wire nothing rather than a second opinion about a surface the host already composed.
+
+ No behaviour changes for any current deployment: `Kernel.use()` keys plugins by `plugin.name` and the host's registration runs after the CLI's, so the host's instance already won by ordering. What changes is that it now wins by rule instead of by the relative position of two blocks that never referenced each other, and the CLI stops constructing four plugins it was about to discard. It becomes visible the moment a host passes an argument the CLI cannot — a private control plane, a custom install `storageDir`, a credential path, white-label branding.
+- 7337f30: 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.
+- f21fe32: 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).
+- 10bbc19: 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.
+- b882020: 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 ` runs, and mirrored in `content/docs/deployment/cli.mdx` —
+ claimed views, actions, and extensions that neither template's `srcFiles` map ever writes.
+ Both templates emit objects only (`src/objects/index.ts` + `src/objects/{namespace}_item.ts`).
+
+ - `app`: `'Full application with objects, views, and actions'` → `'Full application with objects'`
+ - `plugin`: `'Reusable plugin with objects and extensions'` → `'Reusable plugin with objects'`
+ - `content/docs/deployment/cli.mdx`'s template table drops the same false `views` claim from the
+ `app` row.
+
+ A new pin (`packages/cli/test/init.test.ts`) asserts every template's description only claims a
+ metadata kind (`objects`/`views`/`actions`/`extensions`) its `srcFiles` map actually has an entry
+ under, so a future template can't drift the same way.
+- 994e917: fix(cli): `os migrate meta --from N` can finally open the retired-key sources it exists to rewrite (#9418)
+
+ The codemod refused its own input class. A retired authorable key is a
+ `retiredKey()` tombstone — `z.never()` carrying the upgrade prescription — so the
+ current schema does not strip it, it **rejects** it. And a real
+ `objectstack.config.ts` runs that schema itself: `os init` scaffolds
+ `export default defineStack({ … })`, larger projects spread `defineView` /
+ `defineAgent` / `defineFlow` across per-artifact modules, and every one of those
+ `define*` helpers is a `Schema.parse()`. The rejection therefore fired while the
+ config module was being **evaluated**, inside the load, before `os migrate meta`
+ reached its first conversion — the command exited 1 having rewritten nothing.
+
+ The message it printed was the instruction that sent the author there. The
+ sentence "Run `os migrate meta --from ` to rewrite existing sources
+ automatically." ships **144 times across 39 files** under `packages/spec/src`, so the v17 upgrade path closed
+ a loop on itself: hit a retired key, get told to run the codemod, watch the
+ codemod refuse **because of** the retired key.
+
+ **The fix is a tolerant load for that one command.** There was no CLI-side
+ validation step to reorder — the gate lives in the loaded module — so
+ `loadConfig()` gains an opt-in `authoredSource` mode that replaces each
+ `@objectstack/spec` entrypoint the config imports (the root **and** the subpaths
+ the example apps author through, `@objectstack/spec/ui`, `/ai`, `/data`, …) with
+ a generated shim. The shim re-exports the real module and wraps its `define*`
+ helpers as try-real-then-authored: the real helper runs first, and only when the
+ current schema refuses the artifact is it handed on **exactly as authored**, with
+ the swallowed verdict announced on stderr.
+
+ Three properties keep this a restoration rather than a widening of what the
+ command accepts:
+
+ - **A source that loads today loads identically** — the real helper still runs,
+ so its defaults and transforms still apply (`defineForm` still moves
+ `schemaId` into `data`, `defineStack` still merges actions into objects). Only
+ the sources that are refused today take the new path.
+ - **Validation is moved after the conversion, not skipped.** The command still
+ parses the **migrated** stack through `ObjectStackDefinitionSchema` and reports
+ `schemaValid`, so a source broken for reasons the chain cannot fix is still
+ reported as broken — after the codemod has done the part it can.
+ - **Every other command still hears the tombstone.** `os build`, `os validate`
+ and `os serve` keep the default strict load: the rejection is their upgrade
+ channel, and only the codemod is entitled to read past it. Pinned both ways.
+
+ `os migrate meta --stored` was probed and is **not** affected: it never reads
+ `objectstack.config.ts` at all — it boots from the compiled artifact and replays
+ the chain over `sys_metadata` rows, and it already exits 0 in a project whose
+ config carries a retired key. The defect was the authored-source arm alone.
+
+ The regression proof is shaped like a real project rather than like a test — the
+ retired keys are authored through `defineStack` **and** through helpers imported
+ from a spec subpath, which is where a tolerance scoped to `defineStack` alone
+ would still have refused. The suite that shipped alongside the defect could not
+ have caught it: its fixture is a bare `export default { … }` object literal, and
+ a bare literal is validated by nobody at load.
+- 07ad424: `os meta resync`: explain a nonzero skip count instead of leaving it to look like a no-op (#9184)
+
+ `resynced 0 / skipped 8` is a **permanent, by-design** outcome on any install
+ created before #8692's forward-only ruling — the platform's own seeder wrote
+ the `'admin'` stamp those rows still carry, and #8692 deliberately never
+ migrates it (a stored `'admin'` cannot be told apart from a genuine Setup
+ takeover). The docblock half of this (#9130 / PR #9183) already explained it
+ in source; this is the half the operator actually sees, at the terminal,
+ without going looking:
+
+ ```
+ ⚠ Left 8 set(s) untouched (admin- or package-owned).
+ Expected, not a failure — resync only reconciles platform-owned rows. A stored
+ 'admin' stamp (or the legacy 'user' spelling) isn't always a deliberate Setup
+ takeover: on installs from before #8692, the platform's own seeded defaults
+ carry that same stamp, so a persistent skip count here can be permanent by
+ design. A package-owned row, by contrast, is always a deliberate override by
+ the package that owns it.
+ ```
+
+ The new line fires on the same condition the skip-count summary itself
+ already used (`resyncSkipped > 0`) — a partial skip gets the same
+ explanation as a total one. `--json` output is unchanged; `resyncSkipped` is
+ already a plain number a script can act on without prose.
+
+ While here, the skip-count summary's own wording is corrected: it read
+ `(admin- or package-owned override)`, uniformly claiming "override" for
+ both provenance classes. That is accurate for a package-owned row (always a
+ deliberate override, per the seeder's docblock) but was already the exact
+ false framing PR #9183 removed from the per-row log line for the
+ admin-owned case — a pre-#8692 seeded row was never overridden by anybody.
+ The summary now reads `(admin- or package-owned)`, and the new explanatory
+ line carries the nuance instead.
+- 49dba54: fix(cli): `os serve`'s ready banner no longer names a config file that was not read (#8978)
+
+ On an `OS_ARTIFACT_URL` boot (#8368) the `objectstack.config.ts` in cwd is
+ deliberately never executed — the boot diagnostics say so — but the ready
+ banner's `Config:` row still printed it, because `relativeConfig` was derived
+ from `args.config` before the artifact-fallback branch was decided and handed
+ to `printServerReady` unconditionally. The plain artifact-fallback path (no
+ config authored, booting from the `/dist/objectstack.json` convention or
+ `OS_ARTIFACT_PATH`) had the same defect one level worse: the row named a
+ config file that does not exist on disk at all.
+
+ The banner is the surface an operator reads to answer "what is this container
+ actually running" — naming what did NOT boot points them at the wrong app.
+
+ `serve` now reports the resolved artifact's already-redacted `display` string
+ in an `Artifact: … (OS_ARTIFACT_URL)` row when `OS_ARTIFACT_URL` pinned one,
+ omits the row on the other artifact-fallback paths (no safely-redacted value
+ is in hand there), and reports the authored config exactly as before on the
+ ordinary config-boot path.
+- dfedf88: `serve`: warn when the declared replica count exceeds the licensed node cap (#8504)
+
+ The 2026-08-13 `max_nodes` ruling requires a licensed overflow to refuse the excess,
+ run up to the paid limit, and **warn loudly**. The gate learned to express the first
+ two — `admitted` / `refused` / `capped` — but the only program that consults it, `os
+ serve`, called it zero-arg and typed the result with a hand-written
+ `{ allowed, reason }` cast. So the partial-cap verdict was unreachable *and* unread:
+ the gate could say "3 admitted, 2 refused" and nothing rendered it.
+
+ `serve` now passes the operator-declared `OS_CLUSTER_REPLICAS` into the gate and
+ emits an advisory on `capped`:
+
+ ```
+ [cluster] licensed node cap exceeded: the licence admits 3 node(s), but
+ OS_CLUSTER_REPLICAS declares 5 — 2 beyond the cap.
+ [cluster] This cap is ADVISORY and is not enforced yet: nothing is refused, and all
+ 5 replicas will still join the cluster.
+ [cluster] Reduce OS_CLUSTER_REPLICAS to 3, or raise the licensed node limit.
+ ```
+
+ ⚠️ The wording is deliberately advisory. Enforcement needs an atomic slot claim
+ across replicas and is tracked separately; until it lands **nothing is actually
+ refused** — every replica computes the same verdict at boot and none can tell whether
+ it is one of the admitted ones, so all of them join. A message claiming "2 replicas
+ refused" would be false in exactly the declared-vs-delivered way this warning exists
+ to close.
+
+ An outright `allowed: false` denial is untouched: it keeps reporting as a
+ single-node downgrade, and is deliberately not reported as a cap.
+- cb6c821: `objectstack serve` now registers `ObservabilityServicePlugin`, so the `observability:metrics` service actually resolves for every consumer that follows the canonical resolution chain.
+
+ `serve.ts` built one metrics registry from `OS_OBS_EXPORTER` and threaded it into a single consumer (the dispatcher). Nothing in the repo registered the service itself, so the cache and storage adapters walked the documented chain — explicit option, then `observability:metrics`, then a no-op — and held a `NoopMetricsRegistry` in every shipped deployment, however `OS_OBS_EXPORTER` was set.
+
+ The registry is now built once and registered as a service ahead of the transport, the dispatcher and the capability providers, because every consumer resolves the chain during its own `init()`. Measured on a booted showcase app with `OS_OBS_EXPORTER=console`: `storage_operations_total` and `storage_operation_duration_ms` now emit where they previously emitted nothing, and the cache adapter now holds the configured registry instead of the no-op. `http_requests_total` is unchanged — it was already armed transport-wide by the dispatcher through the `IHttpServer.afterResponse` seam, and the per-server latch keeps it at exactly one observer.
+
+ Deployments that leave `OS_OBS_EXPORTER` unset or set to `noop` are unaffected: nothing is registered, and the transport still installs no per-request middleware.
+- a5d2593: test(cli): `os serve`'s unknown-hostname guard gets a test seam — the middleware, refusal included, is now reachable without booting a server (#9442)
+
+ The `OS_ROOT_DOMAIN` guard was a plugin object literal built inside
+ `Serve.run()`, closing over its locals and installing itself on a `http.server`
+ service resolved from the plugin context. Nothing about it was exported or
+ constructible, so every branch — the health/readiness bypass whose own comment
+ says a 404 there "would kill the container", the reserved-subdomain and
+ `/_console` redirect branches, the `/_admin` and `/.well-known` pass-throughs,
+ the lazy env-registry read whose every failure mode falls through — had zero
+ regression coverage.
+
+ It is now `createUnknownHostnameGuardPlugin()`, exported from `serve.ts` the way
+ its sibling helpers are, with `run()` calling it. Behaviour is unchanged: same
+ branches in the same order, same bodies, and `OS_CLOUD_URL` is still read per
+ request rather than captured at install time. What is new is a suite that mounts
+ the real middleware on a real Hono app and pins BOTH directions — every bypass
+ as an explicit pass-through, and the refusal by `error.code` **and** HTTP status
+ together.
+- 593c4bf: feat(spec): `storage` becomes the canonical `CoreServiceName` slot; `file-storage` stays a deprecated v17 alias (#9683)
+
+
+
+ Maintainer ruling, 2026-08-18, verbatim: 「9683 file-storage 可以叫 storage」.
+ The `file-storage` slot was the only `CoreServiceName` member whose spelling
+ diverged from its documented accessor (`services.storage`), with no recorded
+ reason anywhere in the tree.
+
+ - `CoreServiceName` gains `storage` as the canonical member; `file-storage`
+ stays an accepted, deprecated alias within v17 (it is a published enum
+ member — existing `getService('file-storage')` callers keep working).
+ `CORE_SERVICE_PROVIDER` and `ServiceRequirementDef` carry both.
+ - `@objectstack/service-storage` registers the **same instance** under both
+ names (the `http.server` / `http-server` pattern), pinned by an
+ alias-equivalence test.
+ - Every internal consumer resolves `storage`: the HTTP dispatcher, the email
+ plugin's attachment store, and `os migrate files-to-references`. Discovery
+ reports the service under the canonical `storage` key and mirrors the row
+ verbatim under the `file-storage` key for the alias's v17 lifetime, so
+ existing discovery readers (e.g. the console endpoint catalog) keep
+ working.
+ - Docs (`kernel/runtime-services`, `kernel/contracts`) now document the
+ canonical slot; a custom v17 provider for this slot should register both
+ names.
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [ca2e020]
+- Updated dependencies [720ee95]
+- Updated dependencies [34392a1]
+- Updated dependencies [f287435]
+- Updated dependencies [e7bccaa]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [08d6d3c]
+- Updated dependencies [e374b4d]
+- Updated dependencies [5047cb8]
+- Updated dependencies [ed4ca59]
+- Updated dependencies [1408fe3]
+- Updated dependencies [fe90efa]
+- Updated dependencies [445ae4d]
+- Updated dependencies [5aadce3]
+- Updated dependencies [bcf2755]
+- Updated dependencies [a433122]
+- Updated dependencies [bc6434b]
+- Updated dependencies [96f397a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [48032c9]
+- Updated dependencies [40d5b2d]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [8bbf459]
+- Updated dependencies [2277443]
+- Updated dependencies [a751f7d]
+- Updated dependencies [eccb8b2]
+- Updated dependencies [650cd3d]
+- Updated dependencies [b735507]
+- Updated dependencies [91c6c28]
+- Updated dependencies [75b7c24]
+- Updated dependencies [cf0d902]
+- Updated dependencies [498f4e8]
+- Updated dependencies [cc5c07b]
+- Updated dependencies [caaae2c]
+- Updated dependencies [83fe945]
+- Updated dependencies [d9813a9]
+- Updated dependencies [fc89098]
+- Updated dependencies [4c178c1]
+- Updated dependencies [13d7864]
+- Updated dependencies [8640fb2]
+- Updated dependencies [5c38492]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [3508678]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [04d03c3]
+- Updated dependencies [6a51704]
+- Updated dependencies [2d0af57]
+- Updated dependencies [2c570f3]
+- Updated dependencies [7337f30]
+- Updated dependencies [420804d]
+- Updated dependencies [8656d67]
+- Updated dependencies [177442d]
+- Updated dependencies [950bd94]
+- Updated dependencies [3043e98]
+- Updated dependencies [51a46a4]
+- Updated dependencies [c8e85fc]
+- Updated dependencies [3d61924]
+- Updated dependencies [5244fd7]
+- Updated dependencies [cbf4b40]
+- Updated dependencies [9c4d096]
+- Updated dependencies [86431f7]
+- Updated dependencies [716ac9b]
+- Updated dependencies [e9534a4]
+- Updated dependencies [7b3c033]
+- Updated dependencies [6feac91]
+- Updated dependencies [62b1427]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [b2789ad]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [4ea921c]
+- Updated dependencies [0ccea4a]
+- Updated dependencies [3ab2488]
+- Updated dependencies [2b292ce]
+- Updated dependencies [185c7bd]
+- Updated dependencies [abcf853]
+- Updated dependencies [14935ab]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [66dbec4]
+- Updated dependencies [6aceca9]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [45862a5]
+- Updated dependencies [152bff8]
+- Updated dependencies [7ff3975]
+- Updated dependencies [fd6bdf8]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [818c27c]
+- Updated dependencies [19539b4]
+- Updated dependencies [e0695b5]
+- Updated dependencies [01074e5]
+- Updated dependencies [7c3a7eb]
+- Updated dependencies [a9df51c]
+- Updated dependencies [b705a6c]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [ab8b10f]
+- Updated dependencies [4e71ae1]
+- Updated dependencies [739fe5b]
+- Updated dependencies [20067c5]
+- Updated dependencies [bc03179]
+- Updated dependencies [d09d0fd]
+- Updated dependencies [5ed8ee6]
+- Updated dependencies [e783e16]
+- Updated dependencies [ff4ba6a]
+- Updated dependencies [f9d7acf]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [b537855]
+- Updated dependencies [2065e31]
+- Updated dependencies [ead96d0]
+- Updated dependencies [6cb88d9]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4dc8a61]
+- Updated dependencies [c15eb23]
+- Updated dependencies [4d47afe]
+- Updated dependencies [2a9752c]
+- Updated dependencies [b348ac2]
+- Updated dependencies [4fc4a3c]
+- Updated dependencies [b740440]
+- Updated dependencies [90a12fb]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [72050cc]
+- Updated dependencies [d70428a]
+- Updated dependencies [4dfa369]
+- Updated dependencies [9a56784]
+- Updated dependencies [c8806ae]
+- Updated dependencies [bb96297]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [1408ae3]
+- Updated dependencies [5e2f594]
+- Updated dependencies [3b3f67d]
+- Updated dependencies [e2899f6]
+- Updated dependencies [bbd86ed]
+- Updated dependencies [7ff5aa2]
+- Updated dependencies [b6c7690]
+- Updated dependencies [855591f]
+- Updated dependencies [e6e1de4]
+- Updated dependencies [6a12e5e]
+- Updated dependencies [3851f87]
+- Updated dependencies [c73eacd]
+- Updated dependencies [f8537df]
+- Updated dependencies [712e185]
+- Updated dependencies [d693ba1]
+- Updated dependencies [53fc099]
+- Updated dependencies [d2e6b1d]
+- Updated dependencies [88e1bac]
+- Updated dependencies [693c788]
+- Updated dependencies [0961065]
+- Updated dependencies [845e164]
+- Updated dependencies [2a29caa]
+- Updated dependencies [9e2e682]
+- Updated dependencies [09a6eee]
+- Updated dependencies [8d017eb]
+- Updated dependencies [1a7f907]
+- Updated dependencies [0425db9]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [05864fb]
+- Updated dependencies [4e3a4c3]
+- Updated dependencies [3b0b61c]
+- Updated dependencies [326f5de]
+- Updated dependencies [501ed0e]
+- Updated dependencies [f047810]
+- Updated dependencies [30d3752]
+- Updated dependencies [8914915]
+- Updated dependencies [b3de42c]
+- Updated dependencies [21995d7]
+- Updated dependencies [a4c11ad]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [499f55e]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [6a5e6ad]
+- Updated dependencies [30b1c63]
+- Updated dependencies [7fc01db]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [c86799f]
+- Updated dependencies [b030055]
+- Updated dependencies [2416dd5]
+- Updated dependencies [88ef34d]
+- Updated dependencies [990a893]
+- Updated dependencies [5989b0d]
+- Updated dependencies [19db5fa]
+- Updated dependencies [b849e69]
+- Updated dependencies [add2d19]
+- Updated dependencies [5d4d20e]
+- Updated dependencies [2b9d33a]
+- Updated dependencies [ad217b1]
+- Updated dependencies [73010f1]
+- Updated dependencies [52182a6]
+- Updated dependencies [c07d6e8]
+- Updated dependencies [f01c0ee]
+- Updated dependencies [fab693b]
+- Updated dependencies [b53d38e]
+- Updated dependencies [71ac21c]
+- Updated dependencies [192213f]
+- Updated dependencies [42d8990]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [b2a451f]
+- Updated dependencies [c25b2d5]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [147eadc]
+- Updated dependencies [0f59584]
+- Updated dependencies [f6c904a]
+- Updated dependencies [90417a8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [159e299]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [7c2f386]
+- Updated dependencies [56bca91]
+- Updated dependencies [52fbba6]
+- Updated dependencies [d5156b9]
+- Updated dependencies [75e66fc]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [8a9e7f4]
+- Updated dependencies [3d0ded8]
+- Updated dependencies [a726154]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [1258dca]
+- Updated dependencies [91c4ff5]
+- Updated dependencies [a4acb8d]
+- Updated dependencies [d634e66]
+- Updated dependencies [682b86b]
+- Updated dependencies [6a1b45e]
+- Updated dependencies [b278695]
+- Updated dependencies [5126e79]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+ - @objectstack/plugin-auth@17.1.0
+ - @objectstack/plugin-approvals@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/runtime@17.1.0
+ - @objectstack/plugin-security@17.1.0
+ - @objectstack/lint@17.1.0
+ - @objectstack/rest@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/metadata-protocol@17.1.0
+ - @objectstack/objectql@17.1.0
+ - @objectstack/plugin-audit@17.1.0
+ - @objectstack/plugin-email@17.1.0
+ - @objectstack/service-automation@17.1.0
+ - @objectstack/client@17.1.0
+ - @objectstack/driver-sql@17.1.0
+ - @objectstack/cloud-connection@17.1.0
+ - @objectstack/console@17.1.0
+ - @objectstack/service-datasource@17.1.0
+ - @objectstack/plugin-sharing@17.1.0
+ - @objectstack/driver-memory@17.1.0
+ - @objectstack/driver-mongodb@17.1.0
+ - @objectstack/driver-sqlite-wasm@17.1.0
+ - @objectstack/driver-turso@17.1.0
+ - @objectstack/metadata@17.1.0
+ - @objectstack/plugin-hono-server@17.1.0
+ - @objectstack/plugin-pinyin-search@17.1.0
+ - @objectstack/service-settings@17.1.0
+ - @objectstack/service-messaging@17.1.0
+ - @objectstack/observability@17.1.0
+ - @objectstack/mcp@17.1.0
+ - @objectstack/service-analytics@17.1.0
+ - @objectstack/service-storage@17.1.0
+ - @objectstack/service-package@17.1.0
+ - @objectstack/service-cache@17.1.0
+ - @objectstack/service-job@17.1.0
+ - @objectstack/plugin-webhooks@17.1.0
+ - @objectstack/account@17.1.0
+ - @objectstack/setup@17.1.0
+ - @objectstack/formula@17.1.0
+ - @objectstack/plugin-reports@17.1.0
+ - @objectstack/service-queue@17.1.0
+ - @objectstack/service-realtime@17.1.0
+ - @objectstack/service-sms@17.1.0
+ - @objectstack/trigger-api@17.1.0
+ - @objectstack/trigger-record-change@17.1.0
+ - @objectstack/trigger-schedule@17.1.0
+ - @objectstack/verify@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 464ff94302..f3416f2012 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/cli",
- "version": "17.0.0",
+ "version": "17.1.0",
"description": "Command Line Interface for ObjectStack Protocol",
"main": "dist/index.js",
"types": "dist/index.d.ts",
diff --git a/packages/client-react/CHANGELOG.md b/packages/client-react/CHANGELOG.md
index 283aee9f39..19fdca41f0 100644
--- a/packages/client-react/CHANGELOG.md
+++ b/packages/client-react/CHANGELOG.md
@@ -1,5 +1,101 @@
# @objectstack/client-react
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [bc6434b]
+- Updated dependencies [9aa8890]
+- Updated dependencies [48032c9]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [75b7c24]
+- Updated dependencies [caaae2c]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/client@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/client-react/package.json b/packages/client-react/package.json
index 4b22d57e97..e3ebd79c1a 100644
--- a/packages/client-react/package.json
+++ b/packages/client-react/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/client-react",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "React hooks for ObjectStack Client SDK",
"main": "dist/index.js",
diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md
index 95c2601640..8fa988ae1e 100644
--- a/packages/client/CHANGELOG.md
+++ b/packages/client/CHANGELOG.md
@@ -1,5 +1,347 @@
# @objectstack/client
+## 17.1.0
+
+### Minor Changes
+
+- bc6434b: **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.
+
+
+- 9aa8890: **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.
+
+
+- 48032c9: **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.
+
+
+- 7c9c1dd: 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.
+- caaae2c: feat(client): `security.explain()` accepts the `recordIds` batch spelling (#8480)
+
+ The typed `security.explain()` request now declares the optional
+ `recordIds?: string[]` field alongside the existing `recordId?: string`, so a
+ typed-client consumer can reach the batch record-grained explain form added
+ server-side by #8326 without a cast. Type-level and TSDoc only — the method
+ still forwards the request body verbatim over POST; the 200-id cap and the
+ `recordId`/`recordIds` mutual exclusion are validated server-side by
+ `ExplainRequestSchema` (`@objectstack/spec`), unchanged.
+
+### Patch Changes
+
+- 03520eb: 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.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/client/package.json b/packages/client/package.json
index 43059cd661..d4cce14c5e 100644
--- a/packages/client/package.json
+++ b/packages/client/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/client",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Official Client SDK for ObjectStack Protocol",
"main": "dist/index.js",
diff --git a/packages/cloud-connection/CHANGELOG.md b/packages/cloud-connection/CHANGELOG.md
index 98021980c6..ee03ef0a47 100644
--- a/packages/cloud-connection/CHANGELOG.md
+++ b/packages/cloud-connection/CHANGELOG.md
@@ -1,5 +1,433 @@
# @objectstack/cloud-connection
+## 17.1.0
+
+### Minor Changes
+
+- e0695b5: fix(cloud-connection): the four mutating `install-local` routes require the `manage_metadata` capability, and the `x-user-id` header fallback is gone (#8976)
+
+
+
+ **BREAKING for any integration that installs, uninstalls, reseeds or purges a
+ local marketplace package with a principal holding no authoring capability — and
+ for anything that identified itself to these routes with an `x-user-id` header.**
+ Landing after the v17.0.0 cut, so it ships as `minor` under the lockstep
+ launch-window convention.
+
+ `MarketplaceInstallLocalPlugin`'s `requireAuthenticatedUser` asked one question —
+ "is there a session?" — and it was the only check on all four mutating routes:
+
+ - `POST /api/v1/marketplace/install-local` — accepts an **inline manifest**,
+ hot-registers its objects into the shared registry, runs `syncSchemas()`
+ against the shared database, writes the install ledger and runs seed data;
+ - `DELETE /api/v1/marketplace/install-local/:manifestId`;
+ - `POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data`;
+ - `POST /api/v1/marketplace/install-local/:manifestId/purge-sample-data`.
+
+ It also ended in a fallback that trusted a bare **`x-user-id` request header**,
+ commented as being "for cases where auth is disabled (e.g. test stubs)".
+
+ **Measured through the composed plugin, to the point the state actually changes**
+ — `manifest.register()`, `objectql.syncSchemas()`, the ledger file on disk,
+ `SeedLoaderService.load()`, `driver.delete()`. All three principal shapes were
+ indistinguishable, and every effect fired for every one of them:
+
+ | principal | install | reseed | purge | uninstall |
+ |:--|:--|:--|:--|:--|
+ | bare `x-user-id` header, **no session** | **200** | **200** | **200** | **200** |
+ | authenticated, **no** `manage_metadata` | **200** | **200** | **200** | **200** |
+ | authenticated, `manage_metadata` | 200 | 200 | 200 | 200 |
+
+ Nothing downstream refused any of it. The first row is the sharper half: with no
+ session store consulted first, a caller who could reach the port completed a
+ full schema-mutating install and had `installedBy` recorded as a string of their
+ own choosing.
+
+ **Severity by deployment shape.** Metadata is environment-scoped rather than
+ org-scoped, so Layer 0's tenant wall does not reach these writes: on the walled
+ multi-org EE shape this is a cross-tenant write channel — any signed-up user of
+ any customer organization could mutate the schema every other tenant runs on,
+ and `organization_admin` deliberately withholds `manage_metadata` precisely
+ because a tenant administrator is not supposed to. It also nullified the
+ already-implemented cloud-side ruling that AI `build` be structurally closed on
+ that shape: closing the build agent while this route stayed open closed the
+ front door and left the loading dock unlocked. On a single-org self-host the
+ severity is genuinely lower — every user is one tenant's — but "any employee
+ with a login can alter the schema and run seed data" still contradicts the
+ operator-action framing, and the header fallback admitted callers with no login
+ at all. The measurements above are code-path measurements through a composed
+ host, not an exploit demonstrated against a running deployment.
+
+ **The fix.** All four routes now resolve identity **and** capability through
+ `resolveAuthzContext` — the platform's single authorization resolver
+ (`@objectstack/core`) — and demand ADR-0066 D1's `manage_metadata`, the same key
+ the `/meta` write doors carry (#6603, and #8919 for the promotion verbs). A
+ caller with no resolvable principal gets `401 UNAUTHENTICATED`; an authenticated
+ caller without the capability gets `403 FORBIDDEN` naming the capability they
+ need. The refusal is issued before any work, so a refused caller cannot probe
+ what is installed through a downstream error. Service and operator tokens are
+ exempt exactly as elsewhere, with no special case: an API key resolves through
+ the same resolver to its owner's real grants.
+
+ **The `x-user-id` fallback is removed, not mode-gated.** It carried no mode flag
+ to gate it to, and it was the last `x-user-id` trust left in `packages/**`
+ source — the two sibling raw-route surfaces that carried the identical line had
+ it *removed* in favour of this same resolver rather than restricted
+ (`plugin-sharing`'s share-link routes, `service-settings`' settings routes). The
+ one first-party caller of these routes, `os package install`, signs in for a
+ real better-auth session cookie and never sent the header.
+
+ The plugin's mount stays **unconditional** (cloud#1287 moved it out of the
+ `marketplaceUrl` ternary so air-gapped boxes stop 404ing). This is authorization
+ on the routes, not un-mounting the plugin.
+
+ **Anti-drift.** `marketplace-install-local-capability-enumeration.test.ts`
+ derives the mutating routes from the plugin's own route table and compares them
+ against a declared list, so a new mutating install-local route fails the build
+ until it is enumerated and its refusal cases run. Each refusal asserts the
+ ADR-0112 envelope (`code` **and** `status`) *and* that no registry, schema,
+ ledger, seed or delete effect fired — a gate that answers 403 after
+ `syncSchemas()` has run is still the bug.
+
+ Two existing suites whose names read as authorization coverage —
+ `marketplace-install-local-posture-gate.test.ts` (the ADR-0120 D5e ceremony,
+ which the caller satisfies from their own request body) and
+ `marketplace-install-local-tenancy-posture.test.ts` (which selects a seeding
+ path) — now open with an explicit statement of what they do **not** cover and
+ name the file that does, backed by an assertion that the named file exists so
+ the correction cannot rot into a wrong answer. Neither test was weakened.
+- 01074e5: fix(cloud-connection): the `install-local` listing requires an authenticated principal, and narrows `installedBy` / `storageDir` to `manage_metadata` holders (#9011)
+
+
+
+ **BREAKING for any consumer that reads this route anonymously — it now answers `401`
+ — and for any authenticated non-operator consumer that reads `installedBy` or
+ `storageDir` from it.** Landing after the v17.0.0 cut, so it ships as `minor` under the
+ lockstep launch-window convention.
+
+ `GET /api/v1/marketplace/install-local` — the console's Setup → "Installed Apps" list —
+ called **no** identity resolution whatsoever. `handleList`'s first statement read the
+ ledger. After #8976 capability-gated the four mutating doors on this surface, this was
+ the only anonymous door left on it: not a weaker gate, the absence of one, so any caller
+ who could reach the port received `200` and the complete payload.
+
+ **What was disclosed.** Per ledger entry: `packageId`, `versionId`, `manifestId`,
+ `version`, `installedAt`, `installedBy`, `withSampleData`; once per response: `items`,
+ `total`, `storageDir`.
+
+ - `installedBy` is a **platform user id**, and the listing enumerates them across every
+ install.
+ - `storageDir` is an **absolute filesystem path on the host** (#6721 put it on the wire
+ deliberately, for a *signed-in* CLI operator who cannot see the remote host's disk).
+ - The inventory itself is a version-level software bill of materials for the deployment
+ — which packages, at which versions, installed when.
+
+ On the walled multi-org EE shape the inventory and the installer identities are
+ cross-tenant information, for the same reason #8976's write channel was: metadata is
+ environment-scoped, not org-scoped, so Layer 0's tenant wall does not scope this read
+ either. Severity is nonetheless lower than #8976's: this is read-only disclosure, not a
+ write channel. The measurement is a code-path measurement through a composed host, not
+ an exploit demonstrated against a running deployment.
+
+ **The fix — authenticated floor plus field narrowing** (maintainer ruling 2026-08-16):
+
+ | caller | status | `items` / `total` | `installedBy` | `storageDir` |
+ |:--|:--|:--|:--|:--|
+ | anonymous | **401 `UNAUTHENTICATED`** | — | — | — |
+ | authenticated, **no** `manage_metadata` | 200 | served | **omitted** | **omitted** |
+ | authenticated, `manage_metadata` | 200 | served | served | served |
+
+ Splitting the payload rather than gating it whole is the point: "which packages are
+ installed here" and "who installed them and where they live on this host" are genuinely
+ different sensitivities. Demanding `manage_metadata` for the whole read would have
+ withdrawn a console page that ships to non-operator users today, and an authenticated
+ floor alone would have left the user ids and the host path on the wire for every signed-in
+ account.
+
+ The two narrowed keys are **omitted, not nulled** — `null` would be a claim about the
+ ledger ("installed by nobody") instead of a fact about the caller. The console already
+ renders the "installed by" line conditionally and never reads `storageDir`, so a narrowed
+ caller sees the same list minus that one line.
+
+ Identity is resolved by the **same** `resolveInstallPrincipal` the four mutating doors use
+ — `resolveAuthzContext`, the platform's single authorization resolver — not a second
+ session read; two auth mechanisms in one file is how the next gap gets created, and this
+ file has already produced one. The 401 envelope is extracted into one
+ `refuseUnauthenticated` seam shared by all five routes, so a client branching on
+ `UNAUTHENTICATED` never has to learn which door it knocked on. The read door inherits
+ #8976's removal of the `x-user-id` fallback: a bare header is still anonymous.
+
+ **No new capability is minted** (#8919 discipline) — the narrowing reuses
+ `manage_metadata`, matching the `/meta` precedent. The plugin's mount stays
+ **unconditional** (cloud#1287 moved it out of the `marketplaceUrl` ternary so air-gapped
+ boxes stop 404ing); the answer to an unauthorized read is a refusal, never an absent
+ route, and the enumeration suite still asserts the GET is mounted.
+
+ **Pinned.** `marketplace-install-local-list-posture.test.ts` pins all three rows above and
+ states, in its own docblock, that it is the file which answers "is the listing gated?" —
+ the sibling `capability-enumeration` suite answers that only for the mutating doors and
+ deliberately filters the GET out. The non-operator row is pinned in **both** directions
+ (the inventory is present *and* the two fields are absent), because asserting only the
+ absences would keep passing if that caller were refused outright — the option the ruling
+ rejected. The refusal asserts the ADR-0112 envelope (`code` **and** `status`) and that it
+ is issued **before** the ledger is read, so a refused caller cannot probe what is installed
+ through timing or a storage error.
+- 990a893: fix(runtime-config): `OS_PRODUCT_STAGE` / `branding.stage` actually reaches `/api/v1/runtime/config`, so the documented preview-badge switch stops being a no-op (#9252)
+
+
+
+ Running `examples/app-showcase` with `OS_PRODUCT_STAGE=ga objectstack dev` left
+ the Console's "Preview" chip on screen. `RuntimeConfigPlugin` never emitted
+ `branding.stage`, so objectui's `PreviewBadge` — which reads exactly that key —
+ never saw the value, and the switch objectui's app-shell README presents as the
+ operational way to hide the badge did nothing at all.
+
+ **Nobody implemented it, in either distribution.** The card guessed the knob was
+ "honored only by the cloud distribution"; measured with a control first, so the
+ zeros are a reading rather than a broken search:
+
+ | probe | result |
+ |---|---|
+ | `OS_PRODUCT_STAGE`, framework repo-wide | 0 hits |
+ | `OS_PRODUCT_STAGE` / `branding.stage` / `PlatformStage`, cloud repo-wide | 0 hits |
+ | control: `OS_PRODUCT_NAME`, cloud repo | 9 hits |
+ | control: files mentioning `branding`, cloud repo | 18 files |
+
+ So this is the declared-but-unenforced trap in its purest form: a documented
+ operator knob with no producer anywhere. Emitting the key restores an
+ already-declared contract rather than widening a surface — no request that is
+ accepted today becomes rejected, or vice versa.
+
+ **Resolved in the plugin, not threaded through the CLI.** Both halves of the
+ documented interface name this plugin (`OS_PRODUCT_STAGE` **or**
+ `new RuntimeConfigPlugin({ stage })`), every sibling branding key already
+ resolves `config.X ?? OS_X` in the same constructor, and — decisively — the
+ card's own repro constructs its **own** `RuntimeConfigPlugin` in
+ `examples/app-showcase/objectstack.config.ts`, which wins over the CLI's by
+ plugin name. A value threaded through `Serve.RUNTIME_CONFIG_OPTIONS` would have
+ left the reported repro still broken. The cloud distribution inherits the fix
+ for free: its `RuntimeConfigPlugin` extends this one and spreads its config into
+ `super()`, so there is one mechanism answering this question, not two.
+
+ **The value space is closed** — `'preview' | 'beta' | 'ga'`, mirroring the
+ `PlatformStage` union the Console branches on (exported as `PlatformStage`). An
+ unrecognised value is refused and named in a mount-time `warn` listing the
+ accepted spellings, never forwarded: the SPA discards off-contract values
+ anyway, so a passthrough would recreate this bug's exact shape — an operator
+ sets the knob, nothing happens, nothing is said.
+
+ **Unset stays absent.** No `stage` key at all, rather than an empty string or a
+ default invented server-side, so the Console keeps applying its own documented
+ `'preview'` default and nothing that works today changes. The regression proof
+ asserts that direction on **key presence** (`hasOwnProperty`), not
+ `toBeUndefined()` — `{ stage: undefined }` satisfies the latter while being a
+ present property that survives `structuredClone` and shows up in `Object.keys`.
+
+### Patch Changes
+
+- 2277443: fix(cloud-connection,service-automation): stop two plugin classes renaming themselves in the shipped build, and enforce the class-name identity limb against `Ctor.name` (#8645)
+
+ `Serve.providesCapability` (`packages/cli/src/commands/serve.ts`) decides whether a
+ host already supplied a capability's provider by comparing, by equality, both a
+ loaded plugin's `name` and its `constructor.name` against a declared identity
+ list. Every identity registry in that file therefore declares two spellings per
+ provider — the registered `plugin.name` id and the exported class name — and the
+ class-name spelling is a claim about the **built** artifact.
+
+ **Measured against the built packages, two of the 27 declared class-name
+ identities matched nothing at all:**
+
+ ```
+ MISMATCH CAPABILITY_PROVIDERS.automation declared=AutomationServicePlugin runtime=_AutomationServicePlugin
+ MISMATCH Serve.MARKETPLACE_PROXY_IDENTITIES declared=MarketplaceProxyPlugin runtime=_MarketplaceProxyPlugin
+ ```
+
+ Both classes referenced themselves **by name inside their own body** —
+ `MarketplaceProxyPlugin.prototype.version` building the outbound proxy
+ User-Agent, and a `private static` backoff helper called from an instance method
+ in the automation plugin. esbuild rewrites such a class into
+ `var X = class _X { … _X … }` so the inner reference binds to the class binding
+ rather than the outer `var`, and the emitted class reports `_X` as its `.name`.
+
+ There was no user-visible impact, because every guard naming these plugins also
+ declares the registered id, which the instance carries as a plain field no
+ bundler touches. What was dead is the **redundancy**: a guard running on one
+ limb it does not know it is running on is one rename away from failing open —
+ and failing open here means silently mounting a second instance over a host's
+ own.
+
+ Both source idioms are replaced with module-scope declarations, so the shipped
+ classes keep their names. The marketplace proxy's self-reference was also
+ reading a field that was never there (`version` is an instance field, so
+ `prototype.version` was always `undefined`): its outbound `User-Agent` announced
+ the `?? '1.0.0'` fallback on every request and now announces the plugin's real
+ version, `1.1.0`.
+
+ The enforcement half lives in `packages/cli/test/serve-capability-identity.test.ts`:
+ every declared class-name identity, across `CAPABILITY_PROVIDERS` and the four
+ marketplace identity lists, is now compared to the runtime `Ctor.name` of the
+ export it names, and must satisfy `providesCapability` through the class-name
+ limb alone. The `*_IDENTITIES` statics are re-derived from `Serve` itself, so a
+ fifth list cannot be added without being enumerated. #8357's local
+ "modulo one leading underscore" accommodation is retired rather than left as a
+ third spelling of the same rule.
+- 7c3a7eb: Marketplace install-local now registers the per-organization seed replayer alongside its dataset merge, so organizations founded after an install are no longer empty.
+
+ Installing a package merged its `data` blocks onto the kernel's shared `seed-datasets` service but never registered the `seed-replayer` service that consumes them. That replayer is registered in `AppPlugin`'s seeder path, so a host runtime declaring no seed data of its own — `objects: []`, no `data`, which is exactly the shape a marketplace install targets — ended up with datasets present and no replayer. On a walled (`isolated` / `group`) deployment the org-scoping middleware then found the datasets, found no replayer, and did nothing: every organization founded after the install received zero rows of the installed app, while the installer's own organization looked correct because it had been seeded inline at install time.
+
+ `applySideEffects` now calls the runtime's `registerSeedReplayerOnce` next to the merge, on both the install and the rehydrate path. Registration is register-once by construction, so a host that already has a replayer keeps it and is unaffected; the incumbent re-reads the same shared list and replays the newly installed datasets too.
+- d2e6b1d: Cloud-connection refusals now emit the response envelope they declare.
+
+ Eleven error exits on `/api/v1/cloud-connection/*` answered with
+ `error: { code }` and no `message`. `ApiErrorSchema.message` is REQUIRED, so
+ `body.error.message` read `undefined` on the wire for every one of them — the
+ Console had already grown the accommodation that produces, displaying
+ `body?.error?.message ?? body?.error?.code` and so showing a machine code to a
+ human. All eleven now carry a readable message; no status and no code changed.
+
+ `POST /api/v1/cloud-connection/bind/poll` additionally stamped the UPSTREAM
+ RFC 8628 spelling (`expired_token`, `access_denied`, …) straight into
+ `error.code`, which is a closed ADR-0112 vocabulary — so that body failed its
+ own contract. The wire change, for anyone branching on it:
+
+ before: { success: false, data: { pending: false },
+ error: { code: "expired_token" } }
+ after: { success: false, data: { pending: false },
+ error: { code: "DEVICE_CODE_FAILED",
+ declaredCode: "expired_token",
+ message: "Device authorization failed: expired_token" } }
+
+ Nothing is lost: the verbatim upstream spelling now rides `declaredCode`, the
+ open producer-authored channel ADR-0112 declares for a code the serving side's
+ ledger does not know. Read `error.declaredCode` where you previously read
+ `error.code` for the RFC 8628 value; `error.code` is now the registered member,
+ which is what a consumer branching on platform conditions should key on.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [ca2e020]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [e374b4d]
+- Updated dependencies [a433122]
+- Updated dependencies [bc6434b]
+- Updated dependencies [96f397a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [48032c9]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [6a51704]
+- Updated dependencies [2d0af57]
+- Updated dependencies [420804d]
+- Updated dependencies [c8e85fc]
+- Updated dependencies [3d61924]
+- Updated dependencies [5244fd7]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [b2789ad]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [6aceca9]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [20067c5]
+- Updated dependencies [e783e16]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [4fc4a3c]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [7fc01db]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [c86799f]
+- Updated dependencies [5989b0d]
+- Updated dependencies [19db5fa]
+- Updated dependencies [2b9d33a]
+- Updated dependencies [ad217b1]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/runtime@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json
index e631325b18..488b7d943f 100644
--- a/packages/cloud-connection/package.json
+++ b/packages/cloud-connection/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/cloud-connection",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Runtime-side client for an ObjectStack cloud control plane — marketplace browse proxy, install-local, device-code binding, org catalog and installed views, and the /api/v1/runtime/config discovery endpoint. Open mechanism (ADR-0008): the hub service, plan policy, and entitlements stay server-side.",
"type": "module",
diff --git a/packages/connectors/connector-mcp/CHANGELOG.md b/packages/connectors/connector-mcp/CHANGELOG.md
index 9de9db6949..d4b768a365 100644
--- a/packages/connectors/connector-mcp/CHANGELOG.md
+++ b/packages/connectors/connector-mcp/CHANGELOG.md
@@ -1,5 +1,96 @@
# @objectstack/connector-mcp
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Patch Changes
diff --git a/packages/connectors/connector-mcp/package.json b/packages/connectors/connector-mcp/package.json
index 6b568485a1..70213de48a 100644
--- a/packages/connectors/connector-mcp/package.json
+++ b/packages/connectors/connector-mcp/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/connector-mcp",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Model Context Protocol (MCP) connector for ObjectStack — a generic adapter that turns any MCP server's tools into a connector's actions on the automation engine's connector registry (ADR-0024).",
"main": "dist/index.js",
diff --git a/packages/connectors/connector-openapi/CHANGELOG.md b/packages/connectors/connector-openapi/CHANGELOG.md
index 24b1b2d493..4a1cb098e5 100644
--- a/packages/connectors/connector-openapi/CHANGELOG.md
+++ b/packages/connectors/connector-openapi/CHANGELOG.md
@@ -1,5 +1,96 @@
# @objectstack/connector-openapi
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Patch Changes
diff --git a/packages/connectors/connector-openapi/package.json b/packages/connectors/connector-openapi/package.json
index a0a42c79ca..45b2824f25 100644
--- a/packages/connectors/connector-openapi/package.json
+++ b/packages/connectors/connector-openapi/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/connector-openapi",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "OpenAPI 3.x connector generator for ObjectStack — turns a declarative OpenAPI document into connector actions on the automation engine's registry, with a self-contained static-auth HTTP transport (ADR-0023).",
"main": "dist/index.js",
diff --git a/packages/connectors/connector-rest/CHANGELOG.md b/packages/connectors/connector-rest/CHANGELOG.md
index 9d818d1072..c02e213f27 100644
--- a/packages/connectors/connector-rest/CHANGELOG.md
+++ b/packages/connectors/connector-rest/CHANGELOG.md
@@ -1,5 +1,96 @@
# @objectstack/connector-rest
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Patch Changes
diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json
index 993e2ef8d1..94336a9923 100644
--- a/packages/connectors/connector-rest/package.json
+++ b/packages/connectors/connector-rest/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/connector-rest",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Generic REST connector for ObjectStack — the reference concrete connector that registers a `request` action on the automation engine's connector registry (ADR-0018 §Addendum).",
"main": "dist/index.js",
diff --git a/packages/connectors/connector-slack/CHANGELOG.md b/packages/connectors/connector-slack/CHANGELOG.md
index 05f26d09f8..3b829435a7 100644
--- a/packages/connectors/connector-slack/CHANGELOG.md
+++ b/packages/connectors/connector-slack/CHANGELOG.md
@@ -1,5 +1,96 @@
# @objectstack/connector-slack
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Patch Changes
diff --git a/packages/connectors/connector-slack/package.json b/packages/connectors/connector-slack/package.json
index dceb3e5a3b..30cc0b6be7 100644
--- a/packages/connectors/connector-slack/package.json
+++ b/packages/connectors/connector-slack/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/connector-slack",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Slack Web API connector for ObjectStack — registers `chat.postMessage` / `chat.update` / `call` actions on the automation engine's connector registry (ADR-0018 §Addendum, ADR-0022).",
"main": "dist/index.js",
diff --git a/packages/console/CHANGELOG.md b/packages/console/CHANGELOG.md
index f7e8534fcc..e48c6b5404 100644
--- a/packages/console/CHANGELOG.md
+++ b/packages/console/CHANGELOG.md
@@ -1,5 +1,196 @@
# @objectstack/console
+## 17.1.0
+
+### Minor Changes
+
+- 83fe945: 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`
+
+### Patch Changes
+
+- fc89098: 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.
+
## 17.0.0
### Minor Changes
diff --git a/packages/console/package.json b/packages/console/package.json
index 8ef79d22c4..46b661cae6 100644
--- a/packages/console/package.json
+++ b/packages/console/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/console",
- "version": "17.0.0",
+ "version": "17.1.0",
"description": "Prebuilt Console SPA pinned to this @objectstack/framework release. Source of truth: @object-ui/console (https://github.com/objectstack-ai/objectui).",
"license": "Apache-2.0",
"homepage": "https://github.com/objectstack-ai/objectstack/tree/main/packages/console",
diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md
index 2bb09b6827..b2d266e22d 100644
--- a/packages/core/CHANGELOG.md
+++ b/packages/core/CHANGELOG.md
@@ -1,5 +1,480 @@
# @objectstack/core
+## 17.1.0
+
+### Minor Changes
+
+- 2782805: 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.
+- e43d63a: feat(identity): API keys are minted against the minter's active organization, and carry it into the request (#8287)
+
+
+
+ On a deployment running `OS_TENANCY_POSTURE=isolated`, a minted API key could
+ read **nothing at all**. `sys_api_key` carried no organization column, so key
+ authentication established a user but no active organization — and the
+ `isolated` Layer 0 wall is `organization_id = activeOrganizationId`, which with
+ no active organization matches no row. Every organization-scoped read answered
+ `200` with `total 0` while the console went on offering minting, so a tenant
+ admin could mint a valid-looking secret and discover only at call time that it
+ read nothing. (There was no cross-tenant leak — the failure was in the other
+ direction.)
+
+ **The column was absent by an inherited rule, not by oversight.**
+ `resolveInjectedSystemColumns` injects `organization_id` into every registered
+ object *except* `managedBy: 'better-auth'` ones, and `sys_api_key` carries that
+ flag — even though better-auth's `apiKey` plugin is not loaded and the table is
+ hand-rolled ObjectStack. So the fix needs the declaration *and* the ADR-0105 D7
+ extension-field registration to stay consistent. The read side, by contrast,
+ was **already wired**: `resolveApiKeyPrincipal` already read an organization
+ into `tenantId` and `resolveAuthzContext` already adopted it — it was reading a
+ column no mint path ever wrote.
+
+ **What changes**
+
+ - `sys_api_key` declares `active_organization_id` (+ index, and the column is
+ shown in the "My Keys" and "All" list views, because the card's complaint was
+ a credential whose reach its owner could not see).
+ - `POST /api/v1/keys` **inherits** the caller's active organization — there is
+ deliberately no org parameter and no cross-org key — and **re-checks the
+ caller's `sys_member` membership at mint time**, honouring ADR-0091 validity
+ windows. Under a walled posture it refuses (400) rather than minting a key
+ with no organization, and refuses (403) for an organization the caller is not
+ a member of. The mint response echoes the organization the key is pinned to.
+ - The verifier reads **one spelling** (Prime Directive #12): the
+ `row.organization_id ?? row.organizationId` chain it used to carry was a
+ consumer-side tolerance for a producer that did not exist.
+ - An **ex-member's key fails closed at verify time** — no principal, not a
+ degrade to a user-only principal, which would resurrect the same
+ `200 + total 0` silent-empty. Checked at verify rather than by revoking on
+ membership loss, because membership ends through many paths (better-auth org
+ endpoints, SCIM, a direct `sys_member` delete, a lapsing validity window) and
+ a hook must catch every one or it silently misses. It costs **zero extra
+ queries**: the resolver has already read `sys_member` for this user.
+ - **Pre-existing org-less keys are never backfilled** — that would silently
+ upgrade credentials minted under a different promise. They keep working under
+ `single` (no wall) and under `group` (whose wall derives from the owner's
+ memberships independently of the active organization, so they already work
+ there), and are **refused under `isolated`**, where they are provably dead
+ today.
+
+ **The column is deliberately named `active_organization_id`, not
+ `organization_id`** — the `sys_session` spelling, for the same concept: the
+ organization a credential makes *active*. `objectHasOrgIdField` tests for the
+ literal `organization_id`, and Layer 0 exempts objects without it, so the other
+ name would have made `sys_api_key` itself org-walled. Both walled postures
+ exclude NULL, so every pre-existing org-less row would have vanished from its
+ **own owner's** "My Keys" list while, under `group`, continuing to
+ authenticate — a live credential nobody could see or revoke, which is a fresh
+ instance of the very class this change removes.
+- 5f5e234: fix(security): `sys_permission_set.active` and `sys_position.active` now actually stop granting access (#8613)
+
+
+
+ **BREAKING for deployments that already switched a permission set or position
+ off.** Both objects ship a Deactivate action whose confirmation dialog promises,
+ in all four locales, that access stops:
+
+ > Deactivate this permission set? Existing assignments stay in place but stop
+ > granting access until re-activated.
+ > Deactivate this position? Users keep their assignment but the position stops
+ > granting permissions until re-activated.
+
+ Nothing read the column. Measured on the real resolver: a position seeded
+ `active: false` still granted its permission sets, and a permission set seeded
+ `active: false` still returned `posture: PLATFORM_ADMIN` with its system
+ permissions. Deactivation moved a badge in Setup and nothing else — while the
+ admin who had just revoked a compromised or over-broad grant was told the
+ opposite, and whose likely next step was therefore *not* the action that would
+ have worked (delete the set, or remove the assignments).
+
+ **What changes at runtime.** `resolveAuthzContext` / `resolveUserAuthzGrants`
+ (`@objectstack/core`) — the single seam every transport resolves authorization
+ through — now drop a deactivated row **before** any derivation:
+
+ - a deactivated `sys_position` no longer contributes its
+ `sys_position_permission_set` grants, and its name leaves `positions` (so the
+ name-reuse path cannot resolve the same grant one layer down);
+ - a deactivated `sys_permission_set` contributes no name, no
+ `system_permissions`, no `tab_permissions`, **and no `PLATFORM_ADMIN`
+ posture** — the flag is applied before the posture is derived, not after;
+ - the `plugin-security` DB loader applies the same predicate, which is what
+ judges a set reached by NAME through an active position of the same name.
+
+ Both tables were already read at that seam, so this costs **zero new hot-path
+ queries**.
+
+ **⚠️ Read this before upgrading.** Any `sys_permission_set` or `sys_position`
+ row currently carrying `active: false` **stops granting the moment this
+ lands** — on live data, with no migration step to notice. That is the correct
+ direction (it is what the dialog said when someone clicked Deactivate), but on
+ an installation that used the switch believing it was inert it is a real
+ revocation. Before upgrading, list the deactivated rows and re-activate any that
+ are still meant to grant:
+
+ ```
+ GET /api/v1/data/sys_permission_set?filters=[["active","=",false]]
+ GET /api/v1/data/sys_position?filters=[["active","=",false]]
+ ```
+
+ A row whose `active` column is **absent or NULL** is unaffected: the predicate
+ is "explicitly deactivated", never "explicitly active", so rows that predate the
+ column keep granting exactly as before.
+
+ **Break-glass, closed in the same change** (`@objectstack/plugin-auth`).
+ Enforcing the flag opened a one-click, installation-wide lockout: deactivating
+ `admin_full_access` un-makes every platform admin at once, through a payload
+ that touches neither `name` nor any identity table, and re-activating requires
+ the permission the click just took away (the seeders deliberately never
+ reconcile `active`, so no restart restores it). The last-administrator guard now
+ judges that write like the delete and rename spellings it already refused, and
+ an environment whose break-glass set is *already* off is read as emptied rather
+ than as a bootstrap window — so it does not silently disarm the guard for every
+ other identity write. Re-activation itself stays permitted, or the refusal would
+ have no way out from inside the product.
+- f8eb736: feat(security): bind the break-glass standing-key lists to what the authz resolver actually reads — the correspondence stops being prose (#8734)
+
+ `plugin-auth`'s last-administrator guard (ADR-0024 D5.2) decides whether a
+ pending write can empty the administrator population by testing the payload
+ against three standing-key lists (`MEMBER_STANDING_KEYS`,
+ `GRANT_STANDING_KEYS`, `PERMISSION_SET_STANDING_KEYS`). A payload touching none
+ of them is skipped without any reads — so a column `resolveAuthzContext` starts
+ reading that a list omits is a write class the guard **silently stops judging**,
+ on the one path whose failure mode is an installation-wide administrator lockout
+ with no in-product recovery.
+
+ Nothing bound the two together. The correspondence lived in a comment, and it
+ had already gone false once: #6084 wrote — naming `active` explicitly — that
+ everything a permission-set write touches other than `name` is invisible to "who
+ is an administrator". That was true when written; #8613 made `active` a
+ resolution-time predicate and the sentence became false. Nothing mechanical
+ would have caught it, because the guard's own tests stay green precisely when
+ the guard is never consulted.
+
+ **The mechanism is two links, and the first one is a measurement.**
+
+ - `@objectstack/core` now exports `ADMIN_STANDING_SURFACE` — declared beside the
+ resolver, listing every table the administrator-derivation path reads, each
+ classified `derives` or `reads-only` with its reason, and for the deriving
+ tables every column read. It is asserted **equal** to what the real
+ `resolveAuthzContext` reads, observed at runtime through a recording engine
+ that records every property access and every `where` key per table. Observation
+ rather than source extraction because the reads that matter have moved into
+ helpers: `active` is read by `isRowActive(row)` and the ADR-0091 window bounds
+ by `isGrantActive(row, now)`, neither named at the resolver's own call site —
+ the exact shape #8613 had.
+
+ - `@objectstack/plugin-auth` now exports its standing-key lists plus
+ `STANDING_KEYS_BY_TABLE` and `STANDING_KEY_EXCLUSIONS`, and a gate requires
+ every column of that measured surface to have an answer: it is standing-bearing
+ (in a list) or it is excluded with the reason it cannot empty the administrator
+ population. There is no third state — the third state is what `active` was
+ between #6084 and #8613.
+
+ So a resolver change that starts reading a new column fails at the first link
+ until the declaration is updated, and at the second until the guard has an
+ explicit answer for it. Landing #8613 green would have required writing down that
+ deactivating `admin_full_access` cannot empty the administrator population —
+ which is false, and which is what the old comment asserted by accident.
+
+ **No guard behaviour changes.** Every list keeps exactly the values it had; the
+ gate is one-directional by construction (it can only ever demand that the guard
+ judges *more*), because the other direction would put pressure on a break-glass
+ guard to fire less often.
+
+ The table-level half is covered too: a resolver that started deriving
+ administrator standing from a **new** table is invisible to any column-set
+ comparison, since the table is absent from both sides — so the surface enumerates
+ every table the path reads, and an unclassified one fails.
+
+### Patch Changes
+
+- 7ff3975: 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.
+- 24173e9: fix(rest): read an offset-free import cell in the business timezone, not the host `TZ` (#8485)
+
+ `parseDateCell` ended in `new Date(s)`. A spreadsheet cell like
+ `2026-08-01 06:00:00` carries no offset, so ECMAScript resolves it against the
+ **process** timezone, and the instant bulk import stored became a property of
+ the deployment host:
+
+ ```
+ TZ=Asia/Shanghai → 2026-07-31T22:00:00.000Z
+ TZ=UTC → 2026-08-01T06:00:00.000Z
+ ```
+
+ Same file, same tenant, same cell — eight hours apart, decided by a setting
+ nobody authoring the spreadsheet can see, and never consulting the business
+ timezone the route had already resolved one frame up
+ (`ExecutionContext.timezone`, the platform-default → global → tenant cascade).
+
+ Since the export renders `datetime` cells in that business timezone (#8373), the
+ advertised export → edit in a spreadsheet → re-import round trip was lossless
+ only where the host `TZ` happened to equal the business zone. `import-coerce.ts`
+ opens by calling itself "the inverse of `export-format.ts`"; it now is one, and
+ the regression proof asserts inverse-ness on the **pair** — every fixture under
+ a host `TZ` deliberately different from the business timezone, because a test
+ that runs only under a matching `TZ` cannot fail.
+
+ **An offset-free datetime cell is now read in the caller's business timezone**,
+ through `@objectstack/core`'s new `zonedWallClockToUtcMs` — the DST-safe wall
+ clock → instant primitive that `zonedDateStartToUtcMs` (the date-bucket drill
+ path) is now the midnight special case of. One implementation of zone
+ arithmetic, `Intl` offsets from the platform tz database, never hand-rolled;
+ generalising the existing one rather than hand-rolling a second in `rest` is
+ what keeps the export and import halves of this seam from drifting apart again.
+ Two wall clocks are not a bijection with instants, and both degenerate DST
+ readings resolve to the earlier candidate instant — a gap reading lands just
+ before the gap, an ambiguous reading on its first occurrence (pinned, measured).
+
+ Three things deliberately do **not** move:
+
+ - **A cell that carries an explicit offset** (`…Z`, `…+08:00`) already names one
+ instant and is honoured exactly as written. This change affects naive cells
+ only.
+ - **The date-only fast path stays UTC.** `YYYY-MM-DD` is UTC per ECMAScript and
+ a `date` is a timezone-naive calendar day (ADR-0053); sweeping it into the
+ zoned handling to make the code look uniform would silently re-time every
+ date-only import to fix nothing.
+ - **No timezone resolved ⇒ UTC**, never the process clock. That is the fallback
+ the export's cell path takes in the same case, so the round trip stays exact
+ for deployments that configure no zone — and a process-`TZ` fallback would
+ preserve the defect for exactly the deployments that cannot see it. This is
+ the one **behaviour change for existing deployments**: a host with a non-UTC
+ `TZ` and no resolved business timezone previously read naive cells in the host
+ clock and now reads them as UTC. An explicitly resolved `'UTC'` is a resolved
+ zone, not a missing one.
+
+ Two adjacent legs of the same defect, both on the naive-cell path:
+
+ - **A naive cell landing in a `date` or `time` field** now takes the typed
+ components verbatim (`2026-08-01 06:00:00` → `2026-08-01` / `06:00:00`).
+ Those branches also read the process clock, so a host east of the cell stored
+ the *previous calendar day* for a `date` column.
+ - **An xlsx date cell.** An Excel serial date carries no timezone; ExcelJS
+ materialises it as a `Date` whose UTC components are the sheet's wall clock,
+ and `import-prepare.ts` rendered it with `toISOString()` — stamping a `Z` the
+ file never had. That fabricated offset then outranked the business timezone by
+ the very carve-out above, so every real date cell in a user-authored workbook
+ imported as UTC whatever the tenant's zone. It now flattens to the same
+ offset-free `YYYY-MM-DD HH:mm:ss` a CSV export writes, which is what that
+ function's contract already claimed to produce.
+- e1bb0ca: fix(qa): `HttpTestAdapter` resolves the Data Protocol mount from the server's `/discovery`, and falls back to the convention loudly (#7983)
+
+ The record-shaped `os test` action types (`create_record`, `read_record`,
+ `update_record`, `delete_record`, `query_records`) built their URLs from the
+ **defaults** of `RestApiConfigSchema.apiPath` and
+ `CrudEndpointsConfigSchema.dataPrefix`, because the adapter is handed an origin
+ and nothing else. A deployment that moved the mount got a 404 that reads like the
+ suite author's own URL mistake rather than a platform limitation.
+
+ The adapter now asks the server, following the `getRoute` precedent in
+ `@objectstack/client`: **one memoised `GET {apiBase}/discovery` per run** (`os
+ test` builds one adapter for the whole run), addressing whatever `routes.data`
+ advertises, with the schema-derived convention as the fallback. Measured on a
+ booted stack (REST route generator + dispatcher bridge), before and after:
+
+ | deployment | before | after |
+ |---|---|---|
+ | stock | created | created |
+ | `crud.dataPrefix: '/objects'` | `HTTP Error 404` | created |
+ | `api.apiPath: '/api/2026-01'` | `HTTP Error 404` | `HTTP Error 404`, now naming the mount |
+
+ The `apiPath` row is **not** closed, and the reason is structural: `apiPath`
+ moves the base that `/discovery` is itself mounted under, so the document that
+ would name the new mount sits behind the prefix that is missing. The one
+ discovery document at a fixed path does not rescue it — `/.well-known/objectstack`
+ advertises the **dispatcher's** `${prefix}/data`, measured as `/api/v1/data`
+ under all three configs above — so it is deliberately not probed: trusting it
+ would attach a false provenance ("discovery told us") to the same 404.
+
+ Instead that case degrades loudly. Falling back to the convention prints a
+ warning naming the mount it will address, the probe that failed and the remedy,
+ and every 404/405 from a record action now carries the mount it addressed and
+ where that mount came from. `api_call` is unchanged, issues no probe, and remains
+ the escape hatch for a host the probe cannot reach.
+- 402c125: fix(objectql): a temporal filter comparand the platform cannot interpret is refused at the engine door instead of answering 200 with zero rows (#8690)
+
+
+
+ A `datetime` / `date` / `time` field filtered with a bare string the platform
+ cannot read — `last_30_days`, `not-a-date-at-all` — was bound **as written**
+ all the way to the driver, where the comparison is false for every row. The
+ caller received `HTTP 200`, an empty result set, and nothing to indicate the
+ filter was meaningless. An unknown `{placeholder}` in the same position was
+ already refused loudly (`FILTER_TOKEN_UNKNOWN` / 400, listing the resolvable
+ tokens), so one API answered two shapes of unusable comparand two different
+ ways.
+
+ It is concretely reachable rather than theoretical: `last_7_days` /
+ `last_30_days` / `last_90_days` are **declared preset names** in the dashboard
+ schema. The shipped console lowers them to `{N_days_ago}` macros before they
+ reach the API, so the console path was always safe — but a saved report, an
+ integration, an MCP client or an AI-authored query sends the preset name itself
+ and got a silent zero. An empty chart is the hardest failure to debug: it is
+ indistinguishable from "there is genuinely no data".
+
+ Such a comparand is now refused at the ObjectQL engine's single filter
+ collection point, with `code: 'INVALID_FILTER'` and `status: 400`, naming the
+ field, the value, the key path and the spellings that would work. That seam is
+ the one place holding the caller's comparand and the field's **declared type**
+ at the same moment, and every verb (`find` / `findOne` / `count` / `aggregate`
+ / `update` / `delete`) and both filter spellings (the array sugar and the
+ lowered condition) pass through it, so all four backends inherit one answer
+ rather than four. `NativeSQLStrategy` additionally **declines** such a query so
+ the raw-SQL analytics path falls through to that door instead of binding the
+ value into its own statement.
+
+ Deliberately unchanged, each by ruling: a `{placeholder}` keeps its existing
+ refusal one layer down (the door runs before token resolution and steps around
+ them, so `{30_days_ago}` still resolves normally); non-string comparands are
+ untouched (a number is epoch milliseconds, a `Date` is an instant); and the
+ **empty string** keeps today's behaviour exactly — it binds as `''` and matches
+ every non-null row, which is a separate question that remains its own card.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [19539b4]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/core/package.json b/packages/core/package.json
index 547b84e5a9..daa56a2c38 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/core",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Microkernel Core for ObjectStack",
"type": "module",
diff --git a/packages/create-objectstack/CHANGELOG.md b/packages/create-objectstack/CHANGELOG.md
index 712f0872eb..f280be998e 100644
--- a/packages/create-objectstack/CHANGELOG.md
+++ b/packages/create-objectstack/CHANGELOG.md
@@ -1,5 +1,165 @@
# create-objectstack
+## 17.1.0
+
+### Minor Changes
+
+- 1eb28a1: Retire the five remote content templates from the scaffolder's catalog.
+
+ `todo`, `compliance`, `content`, `contracts` and `procurement` were delisted
+ from the official ObjectStack template marketplace and are no longer
+ maintained, but the CLI carried its own hardcoded catalog and never learned
+ that: `--help` recommended all five by name with marketing descriptions, and
+ the `Available:` line on a bad `-t` offered them too.
+
+ - `blank` (bundled, offline) is now the whole catalog, so the help text
+ advertises only what is actually supported.
+ - Asking for one of the five by name — `-t todo` in an old script or tutorial —
+ is refused with a message that says the template was retired, instead of the
+ generic "Unknown template" error that reads as a typo.
+ - The GitHub tarball-fetch path that served the remote templates is removed
+ along with its `tar` dependency; nothing else reached it.
+
+ Note this corrects the catalog at HEAD only. Already-published versions keep
+ advertising the retired templates until a new version of `create-objectstack`
+ is released.
+
+### Patch Changes
+
+- 4906c90: 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.
+ ```
+- f2f09e4: fix(create-objectstack): the scaffolded Dockerfile pins the runtime image to the CLI that builds the artifact, instead of `latest` under a comment saying to pin (#9017)
+
+ `src/templates/blank/Dockerfile` shipped `FROM ghcr.io/objectstack-ai/objectstack:latest`
+ directly beneath a comment instructing the reader to "pin the tag to the
+ `@objectstack/cli` version in your package.json so the runtime matches the CLI that built
+ the artifact" — an instruction the scaffold itself did not follow. Every app made with
+ `npx create-objectstack` shipped that contradiction from day one, and `docker/README.md`'s
+ tag table already scopes `latest` to quick starts while documenting `X.Y.Z` as the
+ production pin.
+
+ Measured on scaffolded output rather than the template's bytes, before the fix:
+
+ ```
+ emitted package.json cli range : ^17.0.0
+ emitted Dockerfile FROM : FROM ghcr.io/objectstack-ai/objectstack:latest
+ agreement (tag vs cli range) : DISAGREE
+ ```
+
+ **The tag is resolved after `install`, from the installed CLI — not from the generated
+ `package.json`.** That file carries a caret RANGE, and the two are not interchangeable:
+ npm resolves `^17.0.0` to the newest 17.x, so pinning the range's floor would ship a
+ runtime image *older* than the CLI that built the artifact — breaking the same promise in
+ a new way. The rolling `:17` tag does match the range's float window but is exactly what
+ the tag table tells production not to use. The resolved version is the only value that
+ makes the sentence true, and it is the rule the repo already applies for this purpose in
+ `.github/workflows/scaffold-e2e.yml` ("Pin the runtime's CLI to the SAME version the
+ generated project actually resolved to — NOT a hardcoded `latest`").
+
+ **Both halves move together.** Pinning the line while leaving an imperative to pin by hand
+ would relocate the contradiction rather than remove it, so the comment above the `FROM`
+ line is replaced in the same rewrite. With `--skip-install` there is no resolved version:
+ the tag stays `latest` and the comment keeps telling the reader to pin — which is true on
+ that path, because there the user really must do it by hand.
+
+ The regression proof asserts on **scaffolded output**, never on the template: it scaffolds
+ with the real copy/sync/pin path, plants an installed CLI whose version is deliberately
+ *not* the range's floor (the normal case, and the one that a package.json-derived tag
+ would get wrong), and checks the emitted `FROM` tag against the emitted `package.json`
+ range with a satisfies-check rather than equality.
+
+ `.github/workflows/scaffold-e2e.yml` now reads the tag it builds its local runtime image
+ under **out of the generated Dockerfile** instead of hardcoding `:latest`. Those were two
+ hand-matched literals; had they skewed, Docker would have quietly pulled the last
+ published image instead of the one built from this checkout, and the job's own stated
+ hermeticity would have been false while it stayed green.
+- 0a5adba: fix(create-objectstack): the blank template's `specVersion` stops shipping eleven majors stale, and the version-time sync covers every declared surface on every template (#9264)
+
+ The one bundled template declared the platform it targets in **two** places that
+ disagreed by eleven majors:
+
+ | file | key | was |
+ |:--|:--|:--|
+ | `objectstack.manifest.json` | `specVersion` | `^6.0.0` |
+ | `objectstack.config.ts` | `engines.protocol` | `^17` |
+
+ `scripts/sync-template-versions.mjs` re-stamped the config key and the template's
+ `@objectstack/*` dependency ranges, and **never opened the manifest at all**. So
+ `engines.protocol` tracked every major bump while `specVersion` sat at the value
+ it held when the script was written — and a green `sync-template-versions` run
+ was never evidence about it, because the script's failure mode was loud for the
+ keys it covered and mute for the key it did not.
+
+ **This is not confined to the registry contract.** `create-objectstack` copies
+ the manifest into every scaffolded project, rewriting `name`, `displayName` and
+ `namespace` and dropping `description` — it has never touched `specVersion`. So
+ every project scaffolded since v7 was stamped with a `^6.0.0` spec range while
+ installing `@objectstack/spec@^17.0.0`.
+
+ **The two keys are two facts, and the fix keeps them apart.** `engines.protocol`
+ is the ADR-0087 D1 runtime handshake range and carries the protocol major
+ (`^17`). `specVersion` is documented by `TemplateManifestSchema` as the
+ "Compatible `@objectstack/spec` semver range" and carries the package range
+ (`^17.0.0`) — the same value the script already writes into the template's own
+ `@objectstack/spec` dependency, so the manifest and the `package.json` now state
+ one fact once. They agree on the major only because the spec package's major and
+ the protocol major are kept in lockstep; they are stamped from two different
+ values.
+
+ Deleting the key was not available: `specVersion` is **required** by
+ `TemplateManifestSchema`, and every shipped manifest is parsed against it by
+ `check:template-manifests`.
+
+ **Two structural changes, because one-key-one-file coverage is what let this
+ sit:**
+
+ - the sync script's file list is now **discovered**, not hard-coded — templates
+ are found by walking `src/templates/`, the same way `check-template-manifests`
+ finds the manifests it parses, so a second template is covered on the day it
+ lands;
+ - **every stamp is required**. A template whose file is missing, whose stamp is
+ absent, or whose `package.json` declares no `@objectstack/*` dependency is a
+ hard failure naming the path — never a skip. A skipped stamp is
+ indistinguishable from a synced one in the log, which is the invisibility this
+ fixes.
+
+ The manifest is rewritten as **text** rather than parsed and re-serialized:
+ `objectstack.manifest.json` keeps `scaffold.variables` compact on one line, and
+ `JSON.stringify(…, null, 2)` would reformat unrelated structure on every release.
+
+ CI coverage lands as four per-template ratchets in `template-consistency.test.ts`,
+ generalized off `blank` onto the same directory walk — including the invariant
+ that catches this exact class: the manifest's `specVersion` must equal the
+ `@objectstack/spec` range the template actually installs. Either file alone can
+ be self-consistently stale; only comparing them catches a stamp that covered one
+ and not the other.
+
## 17.0.0
### Major Changes
diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json
index 940ffa459b..c6db18aaec 100644
--- a/packages/create-objectstack/package.json
+++ b/packages/create-objectstack/package.json
@@ -1,6 +1,6 @@
{
"name": "create-objectstack",
- "version": "17.0.0",
+ "version": "17.1.0",
"description": "Create a new ObjectStack project — npx create-objectstack",
"bin": {
"create-objectstack": "./bin/create-objectstack.js"
diff --git a/packages/drivers/driver-memory/CHANGELOG.md b/packages/drivers/driver-memory/CHANGELOG.md
index 1cb162558f..a8e205af02 100644
--- a/packages/drivers/driver-memory/CHANGELOG.md
+++ b/packages/drivers/driver-memory/CHANGELOG.md
@@ -1,5 +1,123 @@
# @objectstack/driver-memory
+## 17.1.0
+
+### Patch Changes
+
+- 7337f30: 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.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2d0af57]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/drivers/driver-memory/package.json b/packages/drivers/driver-memory/package.json
index 72536d94ae..5ed92bf491 100644
--- a/packages/drivers/driver-memory/package.json
+++ b/packages/drivers/driver-memory/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/driver-memory",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "In-Memory Driver for ObjectStack (Reference Implementation)",
"main": "dist/index.js",
diff --git a/packages/drivers/driver-mongodb/CHANGELOG.md b/packages/drivers/driver-mongodb/CHANGELOG.md
index 417077d0ad..138fbd1c21 100644
--- a/packages/drivers/driver-mongodb/CHANGELOG.md
+++ b/packages/drivers/driver-mongodb/CHANGELOG.md
@@ -1,5 +1,123 @@
# @objectstack/driver-mongodb
+## 17.1.0
+
+### Patch Changes
+
+- 7337f30: 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.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2d0af57]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/drivers/driver-mongodb/package.json b/packages/drivers/driver-mongodb/package.json
index 00f74a4b64..01391b204a 100644
--- a/packages/drivers/driver-mongodb/package.json
+++ b/packages/drivers/driver-mongodb/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/driver-mongodb",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "MongoDB Driver for ObjectStack - Native document database driver via official mongodb client",
"main": "dist/index.js",
diff --git a/packages/drivers/driver-sql/CHANGELOG.md b/packages/drivers/driver-sql/CHANGELOG.md
index 9edaf9f55c..afcbd5ef66 100644
--- a/packages/drivers/driver-sql/CHANGELOG.md
+++ b/packages/drivers/driver-sql/CHANGELOG.md
@@ -1,5 +1,942 @@
# @objectstack/driver-sql
+## 17.1.0
+
+### Minor Changes
+
+- 9c4d096: feat(driver-sql): MySQL joins the unresolvable-column predicate — the `INVALID_FILTER` refusal envelope AND the #3821 recoveries, full dialect parity (#8926)
+
+ **BREAKING** accept-set change on a GA public data API — in both directions at
+ once, on MySQL only — shipped as `minor` under the lockstep launch-window
+ convention, like the #8790 change it completes.
+
+
+
+ ## What changes, on MySQL only
+
+ `isUnresolvableColumnError` — the ONE predicate `SqlDriver.findRows()`'s #3821
+ recovery ladder and `SqlDriver.count()` both read — now recognises MySQL's
+ spelling of "the statement named a column the backend could not resolve":
+ `Unknown column 'x' in 'where clause'` / `'field list'` / `'order clause'`
+ (`ER_BAD_FIELD_ERROR`). SQLite and Postgres behaviour is untouched.
+
+ Measured on live MySQL 8.0.46 (`SqlDriver` over mysql2), before → after:
+
+ - **WHERE** — `find()` and `count()` alike: raw `ER_BAD_FIELD_ERROR`, no
+ `status` (an unclassified 5xx at the REST boundary), the statement's bound
+ literals inlined in the message → refused with `INVALID_FILTER` / 400 naming
+ the column; the dialect message goes to the server log. The narrowing — the
+ #7929 predicate-text disclosure shape closed on the last dialect that still
+ had it.
+ - **Projection** — `find({ fields: [...] })` naming a column the table lacks
+ threw the raw error → retries selecting `*`; the rows come back, WHERE
+ honoured. The widening.
+ - **ORDER BY** — sorting by a column the table lacks threw the raw error →
+ drops the sort and returns the rows unordered, WHERE honoured. The widening.
+
+ Both directions were ruled together on #8926 (option A, maintainer,
+ 2026-08-16); a split predicate — the envelope without the recoveries — was
+ refused. The widening cannot drop a predicate: every ladder rung is rebuilt
+ from `buildBase()`, which unconditionally re-applies `query.where`, so the
+ recoveries reach the projection and the sort only.
+
+ ## Migration
+
+ Nothing stored needs rewriting. A MySQL caller that relied on catching the raw
+ `ER_BAD_FIELD_ERROR` from `find()`/`count()` should catch `INVALID_FILTER` /
+ 400 instead — the same envelope SQLite and Postgres already answer, and the
+ same prescription the registered
+ `driver-sql-unresolvable-where-column-refused` migration carries.
+- 716ac9b: fix(driver-sql): one unresolvable WHERE column, one answer — `find()` and `count()` both refuse with `INVALID_FILTER` / 400 naming the column (#8790)
+
+ **BREAKING** accept-set narrowing on a GA public data API, shipped as `minor`
+ under the lockstep launch-window convention. The migration prescription is
+ registered under protocol major 18, where `os migrate meta` users will look.
+
+
+
+ ## The defect
+
+ One predicate had two answers. `SqlDriver.findRows()` carries the #3821
+ unknown-column recovery ladder, and every rung of it is built from
+ `buildBase()`, which **always re-applies `query.where`**. So the ladder can drop
+ a projection and can drop an ORDER BY, but it can never drop the clause that
+ actually failed when the unresolvable column is in the WHERE — both rungs raise
+ the same error and the method fell to `return []`. `SqlDriver.count()` runs a
+ separate statement and has no ladder at all, so the identical predicate threw.
+
+ Measured on a real `SqlDriver` over better-sqlite3, one table, one seeded row:
+
+ ```
+ where { 'title.x': 'y' }
+ find() -> 0 rows, NO ERROR
+ count() -> THREW code=SQLITE_ERROR status=undefined
+ select count(*) as `count` from `task` where `title`.`x` = 'y'
+ - no such column: title.x
+
+ CONTROL where { title: 'Design' }
+ find() -> 1 row
+ count() -> 1
+ ```
+
+ A list view calls both halves, so one query produced an empty page from the rows
+ half and a 500-shaped failure from the total half. A caller reading only the rows
+ got a silent empty page that says "no records exist" for what was really "your
+ predicate never ran" — the single most AI-legible failure to get wrong, since an
+ agent reads "no matching records" and writes its next query on that belief.
+
+ The thrown half was no better: the dialect's own `code`, no `status` (so an
+ unclassified 5xx at the REST boundary rather than a caller mistake), and the
+ statement's **bound literals inlined in the message** — the same predicate-text
+ disclosure shape #7929 redacted elsewhere.
+
+ ## The fix
+
+ Ruled 2026-08-15 on #8790: **refuse both halves** with `INVALID_FILTER` / 400,
+ naming the column. That envelope is not minted here — it is what every sibling
+ refusal on this path already answers, required on both SQL drivers by
+ `cross-field-conformance-cases.ts` and pinned by
+ `sql-driver-boolean-identity.test.ts` and
+ `sql-driver-cross-field-conformance.test.ts`. What closes is a
+ declared-vs-enforced gap, not a new posture.
+
+ The caller-visible message names the column and the object and nothing else. The
+ dialect's own message — the compiled statement, bound literals and all — goes to
+ the **server log** instead, so the operator keeps the debugging aid that
+ `count()`'s raw throw used to provide without it reaching the caller.
+
+ **The #3821 ladder keeps both of its recoveries.** Only the WHERE-failure
+ terminal `return []` became a refusal, and the asymmetry is the ruling rather
+ than an oversight: "rows matter more than their order" is an argument about how
+ rows are *presented*, and it does not transfer to a predicate. A dropped sort is
+ a correct answer in an unhelpful order; a dropped WHERE is records the caller
+ explicitly excluded. Recover-both was rejected for exactly that reason.
+
+ ## Reach, stated rather than assumed
+
+ The refusal fires on the wordings the ladder has always recognised — SQLite
+ (`no such column: x`) and Postgres (`column "x" does not exist`). MySQL spells
+ the condition `Unknown column 'x' in 'where clause'`, which neither arm matches,
+ so on MySQL an unresolvable column still travels out as the raw dialect error.
+ That gap is pinned as a fact in the new suite and filed separately: widening the
+ predicate would also hand MySQL the #3821 projection and ORDER-BY recoveries it
+ has never had, which is an accept-set change in the opposite direction from this
+ one.
+
+ ## Who is affected
+
+ Callers that reach the driver with a filter key the table has no column for. The
+ ingress doors already refuse this where they can judge — `assertFilterFieldsExist`
+ (`@objectstack/metadata-protocol`) answers `INVALID_FIELD` / 400 for everything
+ reaching `findData`, with the sentence this refusal now echoes verbatim: *a
+ filter on a field that does not exist can only match zero records, so the query
+ was refused instead of answered with an empty list*. What changes is the
+ backstop underneath them: a registry the door could not read, and a dotted key
+ judged on its head segment only.
+- c8806ae: fix(driver-sql): MySQL refuses an upsert whose `conflictKeys` no PRIMARY KEY or UNIQUE index backs — calls that previously "resolved" now throw (#8621)
+
+ **This narrows MySQL's accept set.** A `SqlDriver.upsert(object, data, conflictKeys)`
+ call on MySQL whose conflict target is backed by no PRIMARY KEY and no UNIQUE
+ index used to resolve; it now throws `VALIDATION_ERROR` / 400. That is why this
+ is a `minor` and not a patch: code that ran without error against MySQL will
+ start failing, deliberately, and the rows it was writing were not the rows the
+ caller asked for.
+
+ SQLite and Postgres have refused this exact call since #8445 / #8567, with this
+ exact sentence. MySQL did not, and could not: knex compiles
+ `onConflict([...]).merge(...)` on `mysql2` to `ON DUPLICATE KEY UPDATE`, which
+ takes **no conflict target at all**, so the named keys are dropped before the
+ statement leaves the process and the server is never asked to find an index for
+ them. The existing refusal classifies an error the server raised, so on MySQL it
+ had nothing to classify.
+
+ Measured on live MySQL 8.0.46 — `email` is the column the caller names, `tax_id`
+ carries the only unique index:
+
+ ```
+ seed upsert({email:'a@b.com', tax_id:'T-1', title:'first'}, ['email']) -> resolved
+ B upsert({email:'other@b.com', tax_id:'T-1', title:'second'}, ['email']) -> resolved
+ ONE row: merged on `tax_id`, which the caller never named, across two
+ different `email` values.
+ D seed, then upsert({email:'a@b.com', tax_id:'T-2'}, ['email']) -> resolved
+ TWO rows, both `email='a@b.com'`: the merge that WAS asked for did not
+ happen either.
+ ```
+
+ So the failure being replaced is not an illegible error — it is a silent wrong
+ write. `upsert` now consults the table's physical keys before compiling on MySQL
+ and answers the wording, `code` and `status` the other two dialects already
+ answer (#5240 — one condition, one wording).
+
+ **What this means for an existing MySQL deployment.** The calls that change are
+ exactly those naming a conflict target no key covers — the same calls that have
+ always been errors on SQLite and Postgres. The most likely one to surface is a
+ tenant-scoped `unique: true` field: its index materializes as the composite
+ `(COALESCE(organization_id, '__global__'), field)` (ADR-0120 D3), so
+ `conflictKeys: ['field']` alone is not backed by it. The remedy is the one the
+ refusal already prints: declare the column(s) `unique: true` and re-run schema
+ sync, name the full composite, or upsert on the primary key.
+
+ Deliberately unchanged:
+
+ - **SQLite and Postgres.** They already refuse this from the server, and they
+ attach the server's own sentence as `cause` — ground truth a pre-flight cannot
+ reconstruct. Running the pre-flight there would replace a planner verdict with
+ an introspection verdict for no gain.
+ - **The default `['id']` path.** The pre-flight runs only when the caller names
+ a target; the default is this driver's own primary key on every table it
+ creates, so probing it would add a round trip to every ordinary upsert to
+ answer a question with only one possible answer.
+ - **Anything the pre-flight cannot prove.** A failed introspection, a table
+ reporting no keys at all (indistinguishable from a table that does not exist),
+ and a possibly stale cache all proceed rather than refuse — the cache is
+ re-read from the database before any refusal is thrown.
+
+ **Not fixed here, and filed as #8755:** `ON DUPLICATE KEY UPDATE` carries no
+ conflict target even when the named one IS backed, so on MySQL a second unique
+ index can still absorb the conflict and merge on a key the caller never named.
+ This change closes the unbacked-target hole; it does not make MySQL honour
+ `conflictKeys` as a target.
+- bb96297: fix(driver-sql): refuse a MySQL upsert whose named conflict target another UNIQUE key can absorb (#8755)
+
+ `ON DUPLICATE KEY UPDATE` — the only merge statement MySQL compiles — carries no
+ conflict target, so the merge lands on whichever UNIQUE key the row collides with
+ first. `#8621` closed the half where nothing backed the named target; this closes
+ the half where the target IS backed and a *second* UNIQUE key absorbs the
+ conflict instead.
+
+ Measured on live MySQL 8.0.46, `email` and `tax_id` both `unique: true`, the
+ caller naming `email`: the second upsert merged on `tax_id`, across two different
+ values of the named key, leaving one row and no error. The identical call on
+ SQLite and PostgreSQL raises `UNIQUE constraint failed: …tax_id` and leaves the
+ seeded row untouched.
+
+ **Accept-set change, MySQL only.** An `upsert(object, data, conflictKeys)` naming
+ a non-primary target on a table that carries any other UNIQUE key is now refused
+ before the statement is compiled — `code: 'VALIDATION_ERROR'`, `status: 400`,
+ nothing written and no auto-number reserved. The message names the colliding
+ index and both workarounds: drop or rename the extra UNIQUE key, or run the
+ object on a dialect that honours the target.
+
+ Deliberately unchanged: a table whose only UNIQUE key IS the conflict target (the
+ common shape) merges exactly as before, as do the `conflictKeys`-less default and
+ an explicitly named primary key. The MySQL dialect limit and that residue are
+ documented under *Database Drivers → MySQL*.
+- d00d2f6: fix(driver-sql): refuse — and roll back — a MySQL upsert that merges onto a row the caller never identified (#8807)
+
+ `ON DUPLICATE KEY UPDATE` carries no conflict target, so on MySQL a merge lands on
+ whichever UNIQUE key the row collides with first. `#8621` closed the half where
+ nothing backed a caller-named target; `#8755` closed the half where a rival key
+ could absorb a caller-named one. This closes the residue those two left by
+ construction: the `conflictKeys`-less call and the `['id']` call, which compile
+ byte-identically and which no pre-flight can judge, because neither names anything.
+
+ Measured on live MySQL 8.0.46, `email` and `tax_id` both `unique: true`, **no**
+ `conflictKeys`: seeding `{email:'d@b.com', tax_id:'T-9'}` inserted one row, and
+ `{email:'e@b.com', tax_id:'T-9'}` then resolved with no error — one row, the
+ *seeded* one, its `email` rewritten `d@b.com` to `e@b.com`, and the id the caller
+ was handed back present in no row at all. The identical pair on SQLite raises
+ `UNIQUE constraint failed: …tax_id` and leaves the seeded row untouched.
+
+ Per the maintainer ruling on #8807 this enforces a contract principle, not a MySQL
+ detail: *an `upsert` must never modify a row whose identity the caller did not
+ supply and whose conflict key it did not name.*
+
+ **Accept-set change, MySQL only.** After the statement and inside the same
+ transaction, the driver checks whether the row it landed on is the one the call
+ supplied. If it is not, the write is **rolled back** and the call refuses with
+ `code: 'VALIDATION_ERROR'`, `status: 400`, naming the UNIQUE key that absorbed the
+ merge and stating that nothing was changed.
+
+ The check is exact rather than heuristic — `id` is insert-only on the merge path
+ (#8622), so a row merged on the primary key always still carries the supplied id
+ and a row merged on any other key never does — which is why it has no false
+ refusals.
+
+ Deliberately unchanged: tables whose only key is the primary key are not verified
+ and open no transaction, so the ordinary upsert keeps its single round trip; every
+ insert and every re-upsert of the same row still merges; the caller-named
+ single-unique-key fast path is untouched; and SQLite and PostgreSQL are unaffected,
+ because `ON CONFLICT (...)` already honours the named arbiter. The lifecycle
+ archiver's hot→cold copy passes by construction — it supplies each row's own id —
+ and of the two objects declaring `lifecycle.archive`, neither carries a
+ non-primary unique field. The dialect limit is documented under
+ *Database Drivers → MySQL*.
+
+### Patch Changes
+
+- 8bbf459: 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.
+- 2c570f3: 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.
+- 7337f30: 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.
+- cbf4b40: 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.
+- 86431f7: 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.
+- a9df51c: fix(drivers): withhold the target field from a policy-authored `INVALID_FILTER` refusal (#8197)
+
+ `#7929`/B stopped `driver-sql` echoing the operands of a cross-field
+ `{ $field }` refusal, and `#8220` gave that withhold a spec-declared provenance
+ mark so an author-written predicate gets its diagnostic back. Neither reached
+ the rest of the `INVALID_FILTER` family: five other refusals still named the
+ refused constraint's own **target column** to every caller.
+
+ That column is not always the caller's. The security middleware ANDs an
+ administrator's compiled CEL rule into `opCtx.ast.where`, and on such a
+ predicate the target is as administrator-authored as the referent `#7929`
+ already withholds — the argument that ruling accepted, one step out. The most
+ reachable case is a permission rule over a `multiple: true` field, which lowers
+ to a membership test on a JSON-stored column and is refused by `#7398`'s gate
+ while naming the column the administrator wrote.
+
+ Measured on a real `SqlDriver` (better-sqlite3, `:memory:`) through
+ `driver.find`, all five answered `INVALID_FILTER` / 400 naming the target, and
+ the author-marked spelling was byte-identical to the unmarked one — the mark
+ reached these sites but was never consulted, because none of these builders
+ passed through the withheld-refusal carrier.
+
+ They now do. The five join the seam `#8220` already owns, with its fail
+ direction unchanged:
+
+ - the JSON-column operator gate (`#7398`),
+ - the zero-operator field constraint (`#5240`),
+ - the unbindable comparand (`#5041`) — which also answers a **malformed**
+ `{ $field }`, one whose referent is not a string and so never reaches the
+ cross-field arm,
+ - the `$between` arity refusal,
+
+ plus `driver-turso`'s copied `RemoteTransport.uncompilableComparand`, so one
+ deployment does not disclose differently depending on its connection mode.
+ `driver-sqlite-wasm` inherits `SqlDriver`'s compiler and needed no source
+ change.
+
+ **Who sees what.** A subtree positively marked `'author'` by a read-scope merge
+ boundary keeps the whole diagnostic, target column included. Everything else —
+ `'policy'`, unmarked, and ambiguous — receives the refusal's identity
+ (`INVALID_FILTER` / 400), which class fired, and the capability statement and
+ repair prescription with placeholder names; the naming half goes to the server
+ log. Unmarked withholds by design: the mark is permission to reveal, never a
+ requirement to prove secrecy, and any design where a missing mark lands on the
+ disclosing branch re-opens `#7929`.
+
+ **The accepted cost, stated rather than hidden.** The author-vouch surface is
+ two call sites, and `plugin-security`'s is conditional on `ast.where` still
+ being the caller's verbatim object — which fails once `plugin-sharing` has
+ composed (`#8430`). Until that lands, an author on an object with active
+ sharing rules loses the target-field name from these messages. That is
+ fail-closed, and it is the price of the ruling rather than a defect.
+
+ Redaction takes everything derived from the predicate — the target field, the
+ operator, the comparand preview, the filter path — for the reason `#7929` gave
+ when it withheld both operands rather than one: a comparand preview is the
+ administrator's literal just as surely as a column name is, and half a
+ redaction is none.
+- ab8b10f: test(driver-sql): attribute each `legacyUniqueReplacements` guard to exactly one case (#8557)
+
+ **`patch`, and deliberately not `none`.** This adds no runtime code and changes
+ no behaviour — every assertion is green on `main` before the change. The bump is
+ the floor rather than a skipped changeset because the file it protects is
+ release-relevant: what lands is the pin that makes a future single-guard
+ deletion visible, and the release notes for the version that first carries it
+ are the place a maintainer looks to learn the pin exists. A `minor` would claim
+ a capability; `none` would leave the protection undocumented at the only moment
+ anyone reads for it.
+
+ The declared-index replacement arm's guards were **individually unpinned**:
+ measured on #8468, deleting the ADR-0120 S6 name-identity guard, or admitting a
+ declared bare `unique: true` through the scope filter, left the entire suite
+ green — including the two tests whose names say they cover exactly those cases.
+ The protection was real but collective, so no test attributed it to a line, and
+ a refactor could remove any single guard and be told nothing.
+
+ `schema-drift.legacy-unique-guard-attribution.test.ts` adds that attribution.
+ The existing object-level suites are untouched — they are broader than any one
+ guard, which is why they could not do this job.
+
+ - **Nine guards are individually attributable.** One input per guard,
+ constructed so only that guard can reject it, each paired with a **twin** —
+ the same input with the single property that guard reads changed, which must
+ produce exactly one replacement. The twin is the reachability witness: without
+ it a case would still pass while some earlier guard swallowed the input, which
+ is the failure mode being fixed, one level up. Measured: deleting any one of
+ the nine turns **exactly one** test red, and its name says which line went.
+ - **Five guards cannot be attributed at all**, because another guard rejects a
+ superset of their inputs — deleting one is behaviour-preserving for every
+ possible argument, so a test claiming to pin it would be lying. For those,
+ what is pinned is the **fact the domination rests on**, so the day it breaks
+ and the guard becomes load-bearing alone, something goes red.
+
+ Behind the dominated S6 guard are the hand-written organization composites on
+ `sys_team`, `sys_business_unit` and `sys_member` — three shipped platform
+ objects on a spelling valid indefinitely. Those composites are now pinned
+ directly, in both the shipped bare-`true` spelling and the respelled
+ `'organization'` form.
+
+ The bare-spelling case is the test-side half of a pair whose first half already
+ shipped: #8463 (PR #8512) put the same divergence into prose on
+ `isOrganizationScopedUnique`'s JSDoc, in this same file, with no test attributing
+ it. Routing the declared branch through the field predicate remains the rejected
+ option 1 of #8323 (maintainer ruling 2026-08-13), and is now refused by a test
+ rather than only by a comment.
+- 3b3f67d: Report an un-run MySQL widening ALTER at `error`, naming the fix
+
+ Boot schema-sync widens legacy MySQL `TIMESTAMP` columns to `DATETIME(3)` and
+ zero-precision `TIME` columns to `TIME(3)`. When that DDL cannot run — most
+ often another session holding the table's metadata lock — the failure is
+ swallowed on purpose so a migration never takes boot down. It was reported at
+ `warn`.
+
+ That is the case AGENTS.md's degradation rule names for `error` by name: after
+ the swallow the platform boots, serves traffic and looks entirely normal, while
+ the DDL that was supposed to run did not. An un-widened `TIMESTAMP` keeps
+ truncating milliseconds and an un-widened `TIME` keeps rounding fractional
+ seconds to whole ones, against a canonical storage form that promises the
+ milliseconds are kept, and nothing else reports the column as outstanding.
+
+ Both lines now report at `error` and say what to do about it — identify the
+ metadata-lock holder, end it, then re-run `os migrate apply` or restart, the
+ widening being idempotent. Control flow is unchanged: the swallow stays, and
+ the deferred-DDL flush keeps its loud refusal.
+
+ `scripts/check-durability-degradation-log-level.mjs` gains `runWideningAlters`
+ in its durability vocabulary, so the class stays fixed rather than these two
+ sites.
+- cd455c8: docs: four published READMEs stop documenting symbols and call sites that do not exist (#9544)
+
+ All four packages ship `README.md` in their `files` array with `private` unset, so these
+ are the pages npm renders. Each finding was re-measured against the **built `.d.ts`**, not
+ against source, because that is what a consumer resolves through the `exports` map.
+
+ - **`@objectstack/driver-sql`** — `import type { IDriver } from '@objectstack/spec'` named
+ a type that exists **nowhere in the repository** (0 hits across every package's `src`
+ and `dist`). The real contract is `IDataDriver` on `@objectstack/spec/contracts` — the
+ one `SqlDriver` actually declares (`export class SqlDriver implements IDataDriver`). The
+ adjacent operation list was corrected too: the method is `create`, not `insert`.
+
+ - **`@objectstack/mcp`** — `DriverSql` has never existed (the export is `SqlDriver`), and
+ the README then called `DriverSql.configure({...})` on it. Renaming alone would have
+ been wrong twice over: `SqlDriver` has **no static `configure` either**, and `driver:`
+ is not a key of `defineStack` at all. The example now declares a datasource the way the
+ shipped templates do. `MCPServerPlugin.configure({...})` — five call sites — becomes
+ `new MCPServerPlugin({...})`, the form the class's own JSDoc and every in-repo caller
+ use. The documented options block claimed `serverName`, `autoRegisterTools`,
+ `autoExposeObjects`, `enableStreaming`, `port` and `debug`; the real
+ `MCPServerPluginOptions` is `name`, `version`, `transport`, `autoStart`, `instructions`,
+ and the env switches are named instead.
+
+ - **`@objectstack/objectql`** — `registerObject` is an **instance** method, so
+ `SchemaRegistry.registerObject(...)` on the class could never run. The example now
+ reaches it through the engine's registry and states the real parameter order
+ (`schema, packageId, namespace?`).
+
+ - **`@objectstack/spec`** — the protocol package's own front page imported
+ `MCPServerConfigSchema` from `@objectstack/spec/ai`, which exports `MCPServerRefSchema`.
+ A rename by itself would have swapped a broken import for a broken **parse**: the
+ documented payload was built for a schema that does not exist, and
+ `MCPServerRefSchema.safeParse` rejects it (`transport` is an enum of
+ `stdio | http | websocket`, not an object, and `endpoint` is required and was absent).
+ The example is now a payload that parses green, and the page says plainly that tools,
+ resources and prompts are derived from metadata at runtime rather than authored there.
+- a4acb8d: fix(driver-sql): a merge-path upsert stops rewriting the row's primary key (#8622)
+
+
+
+ `upsert(data, conflictKeys)` on a **business key** — the ordinary way to ingest
+ external data — silently replaced the `id` of the row it merged into. Every
+ relationship, audit record, external id mapping and client-held reference
+ pointing at that row was left dangling, with no error raised on any dialect.
+
+ Measured on a properly BACKED conflict target (`email` declared `unique: true`),
+ so this was the supported path, not an error path:
+
+ ```
+ upsert({ email: 'x@b.com', title: 'first' }, ['email'])
+ upsert({ email: 'x@b.com', title: 'second' }, ['email'])
+
+ [sqlite] before=[{id:'yMh3oywrp0Z6p-oJ', title:'first'}]
+ after =[{id:'d8T8rUlTxlRlaUhN', title:'second'}] idPreserved=false
+ [pg] before=[{id:'T3AlYiyDi5buzGvW', title:'first'}]
+ after =[{id:'TvbCTa5mydWPYP76', title:'second'}] idPreserved=false
+ ```
+
+ One row throughout, as intended — with a different primary key. `upsert` mints a
+ nanoid for any call that supplies none, and `id` travelled in the merge set, so
+ `… on conflict ("email") do update set …, "id" = excluded."id"` wrote the
+ **losing** insert's fresh id over the winning row's. On the default `['id']`
+ conflict target that clause is a no-op (both sides hold the same value), which is
+ exactly why it stayed invisible for so long.
+
+ `id` is now insert-only on the merge path, joining `created_at` and the
+ `auto_number` columns (#7011) in `insertOnlyUpsertColumns` — the same exclusion
+ argument at its strongest instance, since the primary key *is* the platform's row
+ identity. It is resolved through `remoteColumn`, because a federated object can
+ bind `id` to a differently-named physical column (ADR-0015 §18) and a literal
+ `'id'` would filter nothing there.
+
+ **The accept set is unchanged**: the same calls still succeed, still merge, and
+ still advance `updated_at` and every other mergeable column — the merge simply
+ stops rewriting row identity. Re-keying a row deliberately is still `update()`'s
+ job, which writes exactly the columns it is handed.
+
+ Measured on SQLite and live PostgreSQL 16.13. Live MySQL 8.0.46 measured the same
+ rewrite in #8592 and its characterization pin is rewritten here to assert
+ preservation; that cell had no server available in this container and runs first
+ in CI's `Temporal Conformance (live PG + MySQL)` job.
+- 682b86b: fix(objectql): a caller value containing " - " no longer eats the diagnostic's template head, and no longer leaves its own suffix in the log (#9275)
+
+ `redactStatementFromMessage` cuts the bound statement off a driver error at the
+ **last** ` - `, because a bound value may itself contain that separator and
+ cutting at the first would leave a fragment of the value standing.
+
+ When the value the DATABASE inlines into its own diagnostic also contains ` - `,
+ that reasoning inverts: the last separator lands **inside the diagnostic's
+ value**, so the cut discards the template head — the half that could not leak —
+ and keeps a suffix of the caller's data, which is the half that does. Re-measured
+ at HEAD on live PostgreSQL 16.13 with the canary
+ `SENSITIVE-CANARY-9275 - 2026 - Q3`:
+
+ ```
+ raised: insert into "t" ("age") values ($1)
+ - invalid input syntax for type integer: "SENSITIVE-CANARY-9275 - 2026 - Q3"
+ logged: Q3" [statement and bound values redacted]
+ ```
+
+ `Q3` is the caller's data, at ERROR level, which is what this neighbourhood
+ exists to prevent. Families with a right anchor (`for key …`,
+ `for column … at row N`) already recovered through their `tail` pattern; the ones
+ whose value runs to end of message had nothing to recover from.
+
+ **The cut is now template-aware.** When a separator in the message stands
+ immediately before a diagnostic head this file has measured, that separator is
+ the true cut point whatever its position: the head survives and the value after
+ it — separator and all — is dropped whole by the template that owns it. After
+ the fix the same error logs
+ `invalid input syntax for type integer: [value redacted] [statement and bound
+ values redacted]`, so the operator keeps strictly more diagnostic than before.
+
+ **Three families, not the two the card named.** `pg 22003` was left without a
+ head-gone recovery on the reasoning that an out-of-range value is a number and a
+ number cannot contain ` - `. Measured through the driver's own bind path, that is
+ false — Postgres detects the overflow while scanning digits, *before* it rejects
+ the trailing junk, so it echoes the caller's whole string:
+ `insert({ age: '99999999999 - 2026 - Q3' })` logged `Q3` too. It keeps its right
+ anchor, so it takes the #8823 anchor recovery rather than the new cut.
+
+ The trade this takes deliberately, and its bound: matching a template before the
+ cut lets a hostile value steer where the cut lands. That steering is bounded to
+ **over-redaction, never exposure** — a template may declare a head only if its
+ value runs to end of message, so a cut landing inside a statement is swallowed
+ whole by that template; and the **last** matching head wins, so a value that
+ mimics a head is cut at the mimic and cannot survive behind its own decoy. What a
+ crafted value can do is suppress a real diagnostic; that cost is asserted by its
+ own case rather than left to be discovered. The six identifier-bearing families
+ the live probe pins are untouched — over-matching deletes the diagnostic an
+ operator came for, and remains the expensive direction.
+- 6a1b45e: fix(objectql): stop logging the caller's value for four MORE diagnostic families — measured off live MySQL 8.0 / PostgreSQL 16, not read off a manual (#9160)
+
+ #8823 established that a database's diagnostic does not always name only
+ IDENTIFIERS: MySQL's `ER_DUP_ENTRY` inlines the conflicting VALUE, and
+ `redactStatementFromMessage` redacts that one slot while keeping the index name
+ an operator needs.
+
+ The list it introduced had **exactly one entry and no way to notice a second was
+ missing**. Nothing measured whether a diagnostic a driver produced carried a
+ value; the single entry got there because a human read one template closely, and
+ the standing rule (`packages/types/src/unique-violation.ts`) — a dialect's
+ spelling goes in once measured off a thrown error, never from a reading of the
+ manual — correctly prevented the list from growing on a guess.
+
+ **The instrument now exists.** `sql-driver-diagnostic-value-probe.test.ts` plants
+ a canary, raises each candidate family through the driver's own bind path against
+ the live MySQL 8.0 / PostgreSQL 16 services the `Temporal Conformance (live PG +
+ MySQL)` job already stands up, and asserts of every family — value-bearing or not
+ — **where the canary lands**: `error.message` (which `ObjectLogger.write`
+ serializes, so an exposure) or `error.detail` (which it does not). A family that
+ starts inlining a value it did not inline before is now a named red naming the
+ file to edit, instead of a silent leak.
+
+ Measured with a positive control first (`ER_DUP_ENTRY`, the known-value-bearing
+ neighbour, reproduced verbatim — without it a zero elsewhere would be
+ uninterpretable):
+
+ | dialect | family | diagnostic, verbatim | verdict |
+ |:--|:--|:--|:--|
+ | mysql | 1062 | `Duplicate entry 'CANARY' for key 'probe.uq'` | value on `message` (already encoded) |
+ | mysql | 1366 | `Incorrect integer value: 'CANARY' for column 'age' at row 1` | **value on `message`** |
+ | mysql | 1292 | `Incorrect datetime value: 'CANARY' for column 'when_at' at row 1` | **value on `message`** |
+ | mysql | 1264 | `Out of range value for column 'age' at row 1` | identifier only |
+ | mysql | 1406 | `Data too long for column 'label' at row 1` | identifier only |
+ | mysql | 1054 | `Unknown column 'zzz…' in 'field list'` | identifier only |
+ | pg | 22P02 | `invalid input syntax for type integer: "CANARY"` | **value on `message`** |
+ | pg | 22007 | `invalid input syntax for type timestamp with time zone: "CANARY"` | **value on `message`** |
+ | pg | 22003 | `value "99999999999" is out of range for type integer` | **value on `message`** |
+ | pg | 23505 | `duplicate key value violates unique constraint "…"` | value on `detail` only |
+ | pg | 23502 | `null value in column "id" … violates not-null constraint` | value on `detail` only |
+ | pg | 22001 | `value too long for type character varying(20)` | identifier only |
+
+ Both families the card named as candidates **are** value-bearing, and the
+ Postgres one is the sharper result: #8823 recorded that Postgres escapes the
+ unique-violation leak only because its value sits on `error.detail`, a field the
+ logger never serializes — *"coincidence, not a defence"*. `22P02` / `22007` /
+ `22003` put the caller's value on **`error.message`**, the field that IS
+ serialized, so the coincidence does not cover them.
+
+ The one-off regex pair is now an enumerable `VALUE_BEARING_TEMPLATES` table, one
+ row per measured family, each citing the live server that produced it. Every
+ identifier-bearing tail is still kept whole — over-matching deletes the
+ diagnostic an operator came for, which is the expensive direction #8682 paid to
+ avoid, and the six identifier-only families above are pinned against exactly that
+ regression.
+
+ **Known residue, measured and deliberately not closed here:** when the caller's
+ value itself contains ` - `, the statement cut lands inside it and eats the
+ template head. Families with a right anchor (`for key …`, `for column … at row
+ N`) recover; the two whose value runs to end of message (pg 22P02/22007, mysql
+ 1292's `Truncated incorrect …` spelling) have no anchor and leave a suffix
+ standing. Closing that requires the cut itself to become template-aware — a
+ change to #8682's contract, filed rather than decided.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2d0af57]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/observability@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/drivers/driver-sql/package.json b/packages/drivers/driver-sql/package.json
index 6d476ab27a..ee6a6fa9b0 100644
--- a/packages/drivers/driver-sql/package.json
+++ b/packages/drivers/driver-sql/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/driver-sql",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "SQL Driver for ObjectStack - Supports PostgreSQL, MySQL, SQLite via Knex",
"main": "dist/index.js",
diff --git a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md
index 3e3fa0a173..f48c59de1b 100644
--- a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md
+++ b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md
@@ -1,5 +1,134 @@
# @objectstack/driver-sqlite-wasm
+## 17.1.0
+
+### Patch Changes
+
+- 7337f30: 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.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [8bbf459]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2c570f3]
+- Updated dependencies [7337f30]
+- Updated dependencies [420804d]
+- Updated dependencies [cbf4b40]
+- Updated dependencies [9c4d096]
+- Updated dependencies [86431f7]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [a9df51c]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [ab8b10f]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [c8806ae]
+- Updated dependencies [bb96297]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [3b3f67d]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [a4acb8d]
+- Updated dependencies [d634e66]
+- Updated dependencies [682b86b]
+- Updated dependencies [6a1b45e]
+ - @objectstack/spec@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/driver-sql@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/drivers/driver-sqlite-wasm/package.json b/packages/drivers/driver-sqlite-wasm/package.json
index 105bfc3b77..4211f447eb 100644
--- a/packages/drivers/driver-sqlite-wasm/package.json
+++ b/packages/drivers/driver-sqlite-wasm/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/driver-sqlite-wasm",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "WASM SQLite Driver for ObjectStack — runs in browser/WebContainer (StackBlitz) without native bindings",
"keywords": [
diff --git a/packages/drivers/driver-turso/CHANGELOG.md b/packages/drivers/driver-turso/CHANGELOG.md
index a346b20055..ab0b6addc7 100644
--- a/packages/drivers/driver-turso/CHANGELOG.md
+++ b/packages/drivers/driver-turso/CHANGELOG.md
@@ -1,5 +1,192 @@
# @objectstack/driver-turso
+## 17.1.0
+
+### Patch Changes
+
+- 7337f30: 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.
+- a9df51c: fix(drivers): withhold the target field from a policy-authored `INVALID_FILTER` refusal (#8197)
+
+ `#7929`/B stopped `driver-sql` echoing the operands of a cross-field
+ `{ $field }` refusal, and `#8220` gave that withhold a spec-declared provenance
+ mark so an author-written predicate gets its diagnostic back. Neither reached
+ the rest of the `INVALID_FILTER` family: five other refusals still named the
+ refused constraint's own **target column** to every caller.
+
+ That column is not always the caller's. The security middleware ANDs an
+ administrator's compiled CEL rule into `opCtx.ast.where`, and on such a
+ predicate the target is as administrator-authored as the referent `#7929`
+ already withholds — the argument that ruling accepted, one step out. The most
+ reachable case is a permission rule over a `multiple: true` field, which lowers
+ to a membership test on a JSON-stored column and is refused by `#7398`'s gate
+ while naming the column the administrator wrote.
+
+ Measured on a real `SqlDriver` (better-sqlite3, `:memory:`) through
+ `driver.find`, all five answered `INVALID_FILTER` / 400 naming the target, and
+ the author-marked spelling was byte-identical to the unmarked one — the mark
+ reached these sites but was never consulted, because none of these builders
+ passed through the withheld-refusal carrier.
+
+ They now do. The five join the seam `#8220` already owns, with its fail
+ direction unchanged:
+
+ - the JSON-column operator gate (`#7398`),
+ - the zero-operator field constraint (`#5240`),
+ - the unbindable comparand (`#5041`) — which also answers a **malformed**
+ `{ $field }`, one whose referent is not a string and so never reaches the
+ cross-field arm,
+ - the `$between` arity refusal,
+
+ plus `driver-turso`'s copied `RemoteTransport.uncompilableComparand`, so one
+ deployment does not disclose differently depending on its connection mode.
+ `driver-sqlite-wasm` inherits `SqlDriver`'s compiler and needed no source
+ change.
+
+ **Who sees what.** A subtree positively marked `'author'` by a read-scope merge
+ boundary keeps the whole diagnostic, target column included. Everything else —
+ `'policy'`, unmarked, and ambiguous — receives the refusal's identity
+ (`INVALID_FILTER` / 400), which class fired, and the capability statement and
+ repair prescription with placeholder names; the naming half goes to the server
+ log. Unmarked withholds by design: the mark is permission to reveal, never a
+ requirement to prove secrecy, and any design where a missing mark lands on the
+ disclosing branch re-opens `#7929`.
+
+ **The accepted cost, stated rather than hidden.** The author-vouch surface is
+ two call sites, and `plugin-security`'s is conditional on `ast.where` still
+ being the caller's verbatim object — which fails once `plugin-sharing` has
+ composed (`#8430`). Until that lands, an author on an object with active
+ sharing rules loses the target-field name from these messages. That is
+ fail-closed, and it is the price of the ruling rather than a defect.
+
+ Redaction takes everything derived from the predicate — the target field, the
+ operator, the comparand preview, the filter path — for the reason `#7929` gave
+ when it withheld both operands rather than one: a comparand preview is the
+ administrator's literal just as surely as a column name is, and half a
+ redaction is none.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [8bbf459]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2c570f3]
+- Updated dependencies [7337f30]
+- Updated dependencies [420804d]
+- Updated dependencies [cbf4b40]
+- Updated dependencies [9c4d096]
+- Updated dependencies [86431f7]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [a9df51c]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [ab8b10f]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [c8806ae]
+- Updated dependencies [bb96297]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [3b3f67d]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [a4acb8d]
+- Updated dependencies [d634e66]
+- Updated dependencies [682b86b]
+- Updated dependencies [6a1b45e]
+ - @objectstack/spec@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/driver-sql@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/drivers/driver-turso/package.json b/packages/drivers/driver-turso/package.json
index b4af4f7023..121c153053 100644
--- a/packages/drivers/driver-turso/package.json
+++ b/packages/drivers/driver-turso/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/driver-turso",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Turso/libSQL Driver for ObjectStack — Edge-first SQLite with embedded replicas",
"keywords": [
diff --git a/packages/formula/CHANGELOG.md b/packages/formula/CHANGELOG.md
index d8154806e3..19eb5ef732 100644
--- a/packages/formula/CHANGELOG.md
+++ b/packages/formula/CHANGELOG.md
@@ -1,5 +1,88 @@
# @objectstack/formula
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [19539b4]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/formula/package.json b/packages/formula/package.json
index d2de6e48dc..9e46f6653f 100644
--- a/packages/formula/package.json
+++ b/packages/formula/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/formula",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "ObjectStack canonical expression engine — CEL (cel-js) + ObjectStack stdlib + dialect registry",
"main": "dist/index.js",
diff --git a/packages/lint/CHANGELOG.md b/packages/lint/CHANGELOG.md
index 202479e1ab..08787e572e 100644
--- a/packages/lint/CHANGELOG.md
+++ b/packages/lint/CHANGELOG.md
@@ -1,5 +1,565 @@
# @objectstack/lint
+## 17.1.0
+
+### Minor Changes
+
+- 13d7864: Dashboard writes are now judged by `validateWidgetBindings` at the runtime publish gate (#7529). A dashboard widget bound to a dataset that resolves to nothing — previously a `200` on both save and publish, failing only as a runtime error on the live board — is refused at **publish** with a located 422 (`INVALID_METADATA`, the offending key path named). Drafts are unaffected: a draft may still hold a forward reference to a dataset not yet authored, and only the draft→active promotion runs the gate.
+
+ Because rule surfaces are registered per-rule, all six of the rule's error-tier findings now gate a dashboard publish as one reference-integrity class: `widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`, `chart-field-unknown`, `widget-legacy-analytics-unrenderable`, `dashboard-filter-field-unknown`. Warning-tier findings (`table-count-only`, `chart-config-missing`, …) ride the non-blocking `advisories` channel on the save response. Config-authored stacks are unaffected — `os validate` / `os build` / `os lint` already ran this rule; the newly gated population is exactly the `sys_metadata` overlay writes (Studio / REST `/meta` / MCP) that previously bypassed it.
+
+ The per-write snapshot (`RuntimeStackContext`) now carries the live `datasets` collection so bindings resolve against the real dataset universe — without it every legitimate board would read as dangling. Existing stored rows are untouched (the gate blocks new publishes only), and `OS_ALLOW_UNLINTED_METADATA_WRITES=1` remains the migration-window escape hatch.
+- 8640fb2: `os validate`: a dashboard header `modal` action's target resolves against declared PAGES, only (#9013)
+
+ `validateDashboardActionRefs` resolved an `actionType: 'modal'` header button's
+ `actionUrl` the way objectui's `DashboardView` used to dispatch it: a defined
+ action name, a bare object name, or the `_` prefix form
+ (`create_`/`new_`/`add_`/`edit_`/`update_` + a defined object) all passed, and a
+ target naming a declared page ERRORED unless it collided with one of those.
+
+ That mirror is gone. Maintainer ruling objectstack#6739-A (2026-08-09): a
+ `type: 'modal'` string target names a PAGE, only — the spec TSDoc, the published
+ docs and `defineStack`'s cross-reference walk already said so, and objectui#4764
+ / objectui#4782 retired the renderer's object fallback and `DashboardView`'s
+ second copy of the prefix convention (enumerated across both repos' corpora:
+ zero producers). After that, `os validate` blessed exactly the buttons the
+ runtime refuses — the false affordance the rule exists to eliminate — while
+ refusing the one shape the runtime serves.
+
+ **BREAKING** accept-set change on the `os validate` gating tier (landing after
+ the v17.0.0 cut; the lockstep launch-window convention ships it as `minor`):
+
+ - A `modal` header target naming a defined action, a bare object, or a
+ `_` form now **fails** validation. Those buttons already
+ dispatch to a named refusal at runtime.
+ - A `modal` header target naming a declared page now **passes** — it was
+ wrongly refused before.
+
+ ## FROM → TO
+
+ ```ts
+ // before — passed validation; the runtime now refuses the click
+ header: {
+ actions: [{ label: 'New Deal', actionType: 'modal', actionUrl: 'create_opportunity' }],
+ }
+
+ // after — name a declared page…
+ header: {
+ actions: [{ label: 'Intake', actionType: 'modal', actionUrl: 'deal_intake' }], // pages: [{ name: 'deal_intake' }]
+ }
+ // …or, to open an object's form, use the validated first-class shape
+ header: {
+ actions: [{ label: 'New Deal', actionType: 'form', actionUrl: 'opportunity.edit' }],
+ }
+ ```
+
+ There is deliberately no automatic rewrite: a retired-shape target is a
+ name-shaped guess (`create_opportunity` names the page `create_opportunity`, or
+ it names nothing — the ruling explicitly declined keeping the prefix), and only
+ the author knows whether the button meant a page or an object form.
+ `objectstack migrate meta` surfaces the change as a structured TODO (semantic
+ entry `dashboard-header-modal-target-page-only`, protocol major 18).
+
+
+- 8b9eba5: feat(spec): field-level `relatedListFilter` — a declarative default filter for auto-derived related lists (#8704)
+
+
+
+ The field-level related-list family (`relatedList` / `relatedListTitle` /
+ `relatedListColumns`) gains its fourth member, `relatedListFilter` — closing the
+ gap where the only way to filter an auto-derived related list was to abandon the
+ auto-derived record page for a hand-written `record:related_list` page
+ (maintainer ruling 2026-08-15 on #8704).
+
+ - **No new filter dialect**: the key carries the canonical Query-DSL
+ `FilterCondition` (the same authoring face as a query `where`, dataset scope
+ filters, and `summaryOperations.filter`). The FILTER-axis doors therefore
+ apply automatically — the schema door refuses bare date-range preset
+ comparands in ordering positions at parse (#8793), and the engine doors judge
+ the composed query at run time (`formula` keys refused `INVALID_FIELD`,
+ #8296).
+ - **Contract semantics, pinned**: the declared constraint is AND-composed with
+ the parent-relationship condition `{ [referenceField]: parentId }` — an
+ authored constraint, never a user-editable suggestion — and the related-list
+ tab badge count honors the same composed filter, so counts match visible
+ rows. Both clauses are normative in the key's contract text and pinned by
+ tests.
+ - **`@objectstack/lint`**: the shared authored-filter walk (`FILTER_KEYS`) now
+ recognizes `relatedListFilter`, extending the filter-token, empty-combinator
+ and preset-comparand rules to the new position.
+
+ The consumption half (RecordDetailView auto-derivation + tab badge) is
+ objectui#4664, `Blocked-by:` this change; until it lands the key is ledgered
+ `planned` with an author warning.
+- a777944: feat(spec,lint): refuse a bare date-range preset name in an ordering filter comparand at publish time (#8793 — the ruled C half of #8690)
+
+ **BREAKING** accept-set narrowing on a published authoring surface, landing
+ after the v17.0.0 cut (the lockstep launch-window convention ships it as
+ `minor`; the migration prescription is registered under protocol major 18).
+
+ `last_7_days` / `last_30_days` / `last_90_days` and their ten calendar
+ siblings are real, declared preset names — for the dashboard date-filter
+ positions, where the console lowers them to `{date-macro}` bounds before any
+ query is sent. Authored as a bare filter comparand nothing resolves them:
+ measured on #8690, `$gte "last_30_days"` returned HTTP 200 with 0 of 51 rows
+ where `$gte "{30_days_ago}"` returned the 38 in-window. The engine now
+ refuses the bare name on a declared temporal field at query time
+ (`INVALID_FILTER` / 400, PR #8808 — the B half); this change is the
+ authoring-time half the same ruling shipped alongside it.
+
+ **What is refused — ordering positions only, in all three authored filter
+ shapes:** a `$gt` / `$gte` / `$lt` / `$lte` comparand or `$between` endpoint
+ on every carrier of `FilterConditionSchema` (dashboard widget filter, dataset
+ filter, report `runtimeFilter`, page/component filter, rollup filter), a
+ `greater_than` / `less_than` / `before` / `after` / `between` view filter
+ rule value, and an ordering `[field, op, value]` filter triple (the latter
+ two via `@objectstack/lint`'s new gating rule `filter-preset-comparand`,
+ which also runs at the runtime publish gate for `dashboard` / `view` /
+ `object` / `page` / `flow` writes). The refusal names the offending value,
+ the position, and the exact `{date-macro}` window that works.
+
+ **What stays accepted:** the preset names in the dashboard date-filter
+ positions (`dateRange.defaultRange`, a date global filter's `defaultValue`) —
+ the only positions any layer ever resolved them; equality and membership
+ comparands (`{ period: 'this_quarter' }`, `$in: [...]`) — a select/picklist
+ column legitimately stores colliding values, and the engine's field-typed
+ door already covers the temporal case; undeclared strings
+ (`'not-a-date-at-all'`) — the field-typed engine door owns those; and the
+ empty-string cell, which stays its own card by ruling.
+
+ ## FROM → TO
+
+ ```ts
+ // before — parsed green, returned a silent zero (or 400 at query time since #8808)
+ filter: { closed_at: { $gte: 'last_30_days' } }
+
+ // after — rejected naming the window; write the date-macro spelling
+ filter: { closed_at: { $gte: '{30_days_ago}' } }
+ // calendar presets prescribe their pair:
+ filter: { closed_at: { $between: ['{week_start}', '{week_end}'] } }
+ ```
+
+ `DATE_RANGE_PRESETS` moved to `@objectstack/spec/data`
+ (`data/date-range-presets.ts`) with `ui` re-exporting it, so both import
+ paths keep working; `DATE_RANGE_PRESET_MACRO_WINDOWS` (the per-preset macro
+ window table the refusals quote) and `isDateRangePresetName` are new exports.
+
+
+- 1408ae3: feat(lint): the five gating object rules cross the runtime publish gate — `object` writes are now judged by `validateFunctionalCompleteness`, `validateManagedApiMethods`, `lintAutonumberFormats`, `validateRuleCompilability` and `validateRuleSchemaFormats` (#4716)
+
+ An `active`-state `object` save through `saveMetaItem` (Studio's field editor,
+ REST `/meta` item CRUD, an MCP/AI author) is now refused with the existing 422
+ `invalid_metadata` envelope when it carries a defect these five rules judge:
+ an inert `summary`/`lookup`/`select` shape, a managed-API verb the object's own
+ affordances refuse, an autonumber format referencing an unknown field, a
+ `format` regex or `json_schema` schema the runtime's own compilers reject, or a
+ `json_schema` `format` name ajv would silently drop. All five already gated
+ `os validate` / `os build` / `os lint`; the runtime door — the only door a
+ tenant overlay row has — ran none of them.
+
+ Scope is deliberately the five **gating** rules only (the #4716 adjudication):
+ the six advisory-tier object rules stay off the runtime surface, so a clean
+ save's response is byte-identical and no new advisory volume reaches Studio's
+ designer. Draft saves are untouched (D1), stored rows keep being served
+ (ADR-0087 asymmetry — the gate's differential blames a write only for what it
+ adds), and `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still degrades the refusal to
+ a loud log for migration windows.
+
+ Boot-path note: the two schema-judging rules load ajv lazily, only when the
+ judged snapshot actually carries a `json_schema` validation — an ordinary
+ field edit still loads no compiler, which `runtime-lazy-deps.test.ts` now pins
+ as a three-tier contract (parsers never; ajv never without a schema; ajv
+ required, on demand, when one is present).
+- b849e69: fix(lint): ask the provenance question at the fifth blanket-`SYSTEM_FIELDS` read site — `searchableFields` (#8404)
+
+ `validate-searchable-fields.ts` judged a declared `searchableFields` entry
+ against the object-independent `SYSTEM_FIELDS` union, exactly as the four
+ filter/page-binding rules did before #8340 wired them to the per-object index.
+ Both of its gates were correct about EXISTENCE and structurally blind to
+ PROVENANCE: `:345` keeps `searchable-field-unknown` silent for any name in the
+ union, and `resolveAllowedSet` goes further — it manufactures a stub meta for
+ such an entry so it survives the resolution's existence filter exactly as it
+ does at runtime.
+
+ On an ADR-0015 `external` object the platform registers its injected anchors
+ (`owner_id`, `organization_id`, the audit family, …) and provisions no storage
+ behind them (#7865 / #8116), so:
+
+ ```
+ searchableFields: ['name', 'owner_id'] // external object
+ ```
+
+ linted clean, the stub kept the entry in the resolved allow-list, and the
+ view's `$searchFields` narrowing then scanned a column empty on every record —
+ #4830's own failure mode (a narrower search than declared, silently) reached by
+ a different route.
+
+ A new `searchable-field-unprovisioned` rule now warns on such an entry, on the
+ object's own canonical set and on a list view's narrowing alike, reusing
+ `unprovisionedAnchorCause` / `unprovisionedAnchorHint` so the sentence matches
+ the four #8340 rules verbatim rather than becoming a second copy (#4830). WARN,
+ never gating, per #4330's cost asymmetry: the remote schema is not visible to
+ this pass, so the finding describes a degradation rather than a refusal.
+
+ **The `:239` stub is KEPT.** It is not incidental — it is what makes the linter's
+ resolution agree with the runtime's, which resolves the declared branch against
+ the registry field map. Measured by disabling it: the existing "keeps runtime
+ parity when the object declares system columns searchable" test goes red
+ (`expected [] to have a length of 1 but got +0`), because the declaration
+ existence-filters to empty and resolution falls through to the auto-default.
+ Dropping it would have been a behaviour change dressed as a warning.
+
+ The warning is emitted per declared entry in the checker's entry loop, never
+ inside `resolveAllowedSet` — that helper reads the OBJECT's declaration and runs
+ once per narrowing, so warning there would repeat one object-level fact for
+ every view and attribute it to the view's path.
+
+ `checkSearchableFieldList` takes the index as an OPTIONAL trailing parameter,
+ the same shape #8340 gave `checkFieldRefs`: its absence means the caller did not
+ build the index and the provenance question goes unasked — the previous
+ behaviour, preserved for out-of-repo callers (cloud graph-lint, the AI authoring
+ path). Both in-repo callers pass it.
+- 71ac21c: feat(lint): a sharing rule anchored where sharing has nothing to widen is now an authoring-time error (#9698)
+
+ `validateSharingRuleEnforceability` gains its second arm. It already judged a
+ sharing rule's `condition` against the compiler that lowers it; it now judges
+ the rule's `object` against the verdict that decides whether the grant can
+ exist at all.
+
+ Two new `error` ids, both decidable from authored metadata before anything
+ boots, and both mirroring `SharingService.inertGrantReason` (ADR-0111 D7)
+ rather than modelling it:
+
+ - **`sharing-rule-object-not-shareable`** — the anchor object's effective
+ sharing model is `public` (an explicit `sharingModel: 'public_read_write'`,
+ or no `sharingModel` on a system object, which ADR-0090 D1 resolves to
+ public). Sharing only ever WIDENS an OWD baseline, so on the widest baseline
+ there is nothing to widen.
+ - **`sharing-rule-object-controlled-by-parent`** — the anchor is a
+ master-detail detail, whose visibility is derived from its master
+ (ADR-0055). It gets its own id and its own fix-it ("share the master
+ record instead"), because `effectiveSharingModel` collapses it onto the same
+ `public` verdict while the correct repair is completely different.
+
+ Both were previously accepted by `SharingRuleSchema`, accepted by `defineRule`,
+ seeded into `sys_sharing_rule`, and only then refused — once per boot, as a
+ WARN line inside the boot diagnostics block. That WARN is not a sufficient
+ diagnostic, and the reason is measured rather than argued: a rule whose criteria
+ match no seeded row never reaches `grant`, so it never throws and warns nothing
+ while being exactly as dead. The WARN is a function of the DATA; the defect is a
+ property of the DECLARATION.
+
+ **Blast radius, measured through `objectstack build` before deciding the
+ severity:** 5 sharing rules are declared in this repo. 3 fire, all of them in
+ `examples/app-crm` — `share_high_value_opps_with_managers`,
+ `share_active_leads_with_manager` and `share_won_deal_activities`, anchored on
+ `crm_opportunity`, `crm_lead` and `crm_activity`, every one of them
+ `sharingModel: 'public_read_write'`. They have been failing their boot backfill
+ on every boot of that app since they were written, and they are removed here
+ under ADR-0049 enforce-or-remove — the same call #9237 made for the two
+ equivalent rules in `app-showcase`. The other 2 (app-showcase's, both on
+ `private` objects) stay silent, which is the direction that had to be proven
+ rather than hoped for.
+
+ The CRM's smoke test used to assert that these rules existed and were of the
+ enforced `criteria` type. Both assertions passed while all three rules enforced
+ nothing, so the assertion is replaced by the property their greenness hid: no
+ declared rule may be anchored where sharing has nothing to widen.
+
+ Deliberately NOT judged, because they are not decidable from authored metadata:
+ the `owner_id` arm (`owner_id` is injected by the schema registry, so asserting
+ it would fail every object that correctly does not declare it by hand), the
+ `bypassObjects` arm (plugin configuration, not stack metadata), and the
+ federated phantom-anchor arm (a provenance test over that same injected column).
+- 192213f: Three write-surface lint rules now ask provenance, not just membership, before exempting a system column (#8663).
+
+ `validate-hook-body-writes`, `validate-action-body-writes` and `validate-flow-node-writes` share one `IMPLICIT_FIELDS` set, which is object-INDEPENDENT: it answers "could this name be implicitly writable somewhere", never "did the platform provision a column for it on THIS object". On an ADR-0015 `external` object those diverge — the registry injects `owner_id` / `organization_id` / the audit family onto a federated object exactly as onto a local one, but the remote database owns the schema and no column exists behind them.
+
+ Each rule now emits a new advisory finding on that path instead of staying silent — `hook-body-write-unprovisioned-anchor`, `action-body-write-unprovisioned-anchor`, `flow-node-write-unprovisioned-anchor` — sharing the `unprovisionedAnchorCause` / `unprovisionedAnchorHint` wording the read-axis rules already use. All three are `warning`: the flow-node rule's existence finding still gates at `error`, and its provenance finding deliberately does not, because the claim is about a remote schema this repo cannot see.
+
+ An author-DECLARED column of the same name is untouched — on a federated object it maps a remote column the author vouches for. `FlowNodeWriteSeverity` widens from `'error'` to `'error' | 'warning'` accordingly.
+- 42d8990: feat(lint): refuse a list-view `sort` that names a formula field, or no field at all, at authoring time (#9257)
+
+
+
+ **BREAKING** accept-set narrowing on a published authoring surface, shipped as
+ `minor` under the same lockstep launch-window convention the sibling
+ `filter-preset-comparand` refusal used. Measured against the shipped corpus
+ before landing at `error`: **56 reachable `sort` declarations across
+ `examples/app-showcase`, `examples/app-crm`, `examples/app-todo` and
+ `packages/platform-objects`, 0 violations** — so this narrows the accept set
+ without failing any metadata that ships today.
+
+ The SORT axis had a runtime refusal on both doors and no authoring gate. This
+ adds the missing half, which is the exact shape #6674 closed for the SEARCH
+ axis one axis over.
+
+ **What was broken.** `ListViewSchema.sort` is
+ `z.union([z.string(), Array<{ field, order }>])`, so the field name is a bare
+ string and Zod validates only the shape. A list view authored with
+ `sort: 'expected_revenue desc'` — a `formula` field — validated, published, and
+ reported valid, then answered `400 INVALID_SORT` on **first load and every
+ load**: the declared sort is the view's initial fetch, not an optional
+ interaction, so the whole view fails with a status the author cannot connect to
+ the declaration. Both runtime doors already refuse it — `assertSortFieldsExist`
+ (`@objectstack/metadata-protocol`, #6994) at the REST ingress and
+ `assertOrderByIsMaterializable` (`@objectstack/objectql`, #7095) on the engine's
+ own boundary — and neither can reach the author.
+
+ **What is refused**, at `error`, on every list-view sort a stack declares
+ (`objects[].listViews.*.sort`, `views[].list.sort`, `views[].listViews.*.sort`):
+
+ - `sort-field-unknown` — the name resolves to no field on the bound object.
+ Judged on the head segment, matching the ingress gate's own rule so the two
+ doors cannot disagree about which names are unknown.
+ - `sort-field-unsortable` — the name is a real field whose type is **virtual**:
+ computed on read, no stored column, nothing for any driver to `ORDER BY`. An
+ unrefused sort on one returns `asc` and `desc` in byte-identical order.
+
+ **What stays accepted, and this is the load-bearing half:** `summary` and
+ `autonumber` sorts. Virtuality is judged by `isVirtualSearchField` /
+ `SEARCH_VIRTUAL_TYPES` (`@objectstack/spec/data`), pinned to `formula` alone —
+ the same spec storage fact the search ingress gate, the engine's search
+ resolution and the FILTER axis' dotted-head classifier already read. It is
+ deliberately **not** the spec's `COMPUTED_VALUE_TYPES`: that set is the WRITE
+ contract ("never client-written") and gating a sort with it would refuse the two
+ types that sort correctly — `summary` is a `table.float` the engine maintains,
+ `autonumber` a `table.string` the engine assigns. Both directions are pinned by
+ test, and the predicate boundary itself is pinned alongside them so the two
+ "must not flag" cases cannot quietly stop meaning anything.
+
+ Registry-injected system columns (`created_at`, `owner_id`, …) are skipped:
+ they are real at runtime, never appear in authored `fields`, and `created_at` is
+ the single most common ordering in the platform's own list views.
+
+ ## FROM → TO
+
+ ```ts
+ // before — parsed green, published, then 400 INVALID_SORT on every load
+ listViews: {
+ forecast: { type: 'grid', sort: [{ field: 'expected_revenue', order: 'desc' }] },
+ }
+
+ // after — refused at authoring time, naming the field, the position and the fix
+ listViews: {
+ // denormalise the computed value onto a stored column and sort by that
+ forecast: { type: 'grid', sort: [{ field: 'expected_revenue_stored', order: 'desc' }] },
+ }
+ ```
+
+ The rule joins `REFERENCE_INTEGRITY_RULES`, so it runs on `os validate`,
+ `os lint` and `os compile` at once rather than being wired per command.
+
+### Patch Changes
+
+- 34392a1: 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.
+- 62b1427: 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.
+- 818c27c: 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.
+- e43b211: fix(spec): the retirement prescriptions state what `os migrate meta` actually does (#9529)
+
+ Every `retiredKey()` prescription whose surface an ADR-0087 conversion covers
+ closed with a maintainer-ruled sentence (2026-08-09, #6856):
+
+ > Run `os migrate meta --from N` to rewrite existing sources automatically.
+
+ The command has never rewritten an authored source file. It replays the
+ conversion chain over the loaded stack **in memory**, prints the attributed
+ mechanical change list (`Applied N mechanical change(s)`, one line per site as
+ `path: from → to (conversionId)`), and writes exactly one file — the `--out`
+ JSON snapshot, when you ask for it. Every write site in
+ `packages/cli/src/commands/migrate/meta.ts` is that snapshot; there is no
+ `--write` / `--fix` / in-place flag. So an author who followed the prescription
+ got the chain replayed, a printed diff and optionally a JSON document in a shape
+ their per-artifact `.ts` modules are not written in — and then still edited every
+ file by hand, with nothing in the message saying so.
+
+ Under the maintainer's ruling of 2026-08-18 the sentence is withdrawn in favour
+ of an honest one, class-wide:
+
+ > Run `os migrate meta --from N` to list the mechanical edits for existing
+ > sources; apply them by hand.
+
+ The partial-value conversions keep their two-clause shape, reworded the same way
+ (`… to list the mechanical edits for the \`1y\` case; the other durations are
+ reported for you to re-state.`). Behaviour is unchanged in both packages — this
+ is message text only, and no accept/reject verdict moves.
+
+ The claim is withdrawn from every shipped site, not only the canonical sentence:
+ the variant phrasings in tombstone and conversion-registry prose ("rewrites
+ author sources", "rewrites it for you", "only `os migrate meta` rewrites
+ sources") go with it, as do the upgrade-path statements in the hand-written docs
+ (`upgrading.mdx` now carries the same "does not rewrite your source files" fact
+ the `objectstack-upgrade` skill already told operators). The class-wide pin
+ `packages/spec/src/shared/retired-key-migrate-sentence.test.ts` moves in
+ lockstep and now holds **both** directions: the new sentence is required where a
+ prescription names the command, and the withdrawn claim is a hard failure
+ wherever it reappears — including in a prescription that spells the bare command
+ without `--from N`, which the sentence-shape check alone would not have seen.
+
+ The in-place AST codemod that would make the original claim true is commissioned
+ separately for v18 (#9591); when it lands, the sentence may be restored by
+ editing that one pin in the same PR.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [19539b4]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/formula@17.1.0
+ - @objectstack/sdui-parser@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/lint/package.json b/packages/lint/package.json
index ed1d7705e0..712f8c439d 100644
--- a/packages/lint/package.json
+++ b/packages/lint/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/lint",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Static, build-time validation for an ObjectStack metadata graph — dashboard widget bindings, CEL/predicate expressions, and more. Pure (stack) => Issue[] functions shared by the CLI's `os validate` and any other consumer (e.g. AI authoring). Depends on @objectstack/spec; never on a runtime.",
"type": "module",
diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md
index 831c3b16f9..06dd7de638 100644
--- a/packages/mcp/CHANGELOG.md
+++ b/packages/mcp/CHANGELOG.md
@@ -1,5 +1,264 @@
# @objectstack/plugin-mcp-server
+## 17.1.0
+
+### Minor Changes
+
+- 20067c5: fix(runtime,mcp,service-datasource): the #6504 consumer sweep — three list consumers stop making claims a known-partial read cannot support (#6504)
+
+
+
+ `IMetadataService.listDiagnosed?(type)` (PR #7721) lets a plural read say whether
+ its answer can be trusted as complete. This is the consumer half: the callers
+ that were restating a possibly-short listing as a fact about the environment.
+
+ Each consumer was qualified individually, per PR #6051's discipline, and most
+ were left alone — a caller publishing a snapshot with no count has nothing to
+ mis-state. Three make a claim, and each now withholds exactly that claim while
+ still serving everything it could read:
+
+ - **`removeDatasource` no longer deletes on a bound-object count it could not
+ take completely.** The guard `if (bound > 0) throw` is the only thing standing
+ in front of an irreversible delete that also unbinds the datasource's secret,
+ and its input is derived from the metadata service's object listing. During a
+ loader outage that listing goes silently short, and the worst value is the
+ benign one: `0` reads exactly like "nothing is bound", so the guard OPENED.
+ It now refuses with `SERVICE_UNAVAILABLE` / 503 — a dependency outage the
+ operator can retry, not a client error — and the record, its credential and
+ its pool all survive.
+ - **The MCP `list_objects` tool stops publishing `totalCount` on a known-partial
+ listing.** This is the same claim PR #7721 removed from the
+ `objectstack://objects` resource, on the other MCP primitive: same payload
+ shape, different door, never covered. A degraded read now serves the same
+ objects with `totalCount` **absent** and `partial` / `returnedCount` /
+ `warning` plus the 503 envelope in its place, so a client reading the total
+ gets `undefined` rather than a believable wrong integer. Both bridges
+ implement it — stdio (`@objectstack/mcp`) and HTTP (`@objectstack/runtime`) —
+ because a completeness claim must not depend on which transport a client
+ connected over.
+ - **The ADR-0015 §5.2 boot gate stops announcing an all-clear over a sweep it
+ could not complete.** It validated whatever `listObjects()` returned and then
+ logged *all federated objects match their remote schema*, with a count.
+ Federated objects behind an unreadable loader were never validated, so
+ `onMismatch: 'fail'` could not have fired for them. The gate now warns that
+ the swept set was incomplete and names what it did validate. ⛔ It does **not**
+ abort boot on a degraded metadata read: turning a transient outage into a
+ refusal to start would be a new failure mode bought with a diagnosis fix.
+
+ Every new member is optional in the same way `listDiagnosed` itself is: a host
+ whose metadata service predates the verdict behaves exactly as it did before,
+ and a service without it reports nothing degraded — precisely what it could
+ express.
+
+### Patch Changes
+
+- ff4ba6a: fix(mcp): the skill prompt bridge reads the protocol's merged metadata listing, so a runtime `PUT /api/v1/meta/skill/` reaches MCP prompts (#8328)
+
+ The bridge read `IMetadataService.list('skill')` — one layer below where the
+ `sys_metadata` overlay merge happens — so an override returned 200 and never
+ reached the prompt surface while `GET /api/v1/meta/skill` served it. The
+ long-lived (stdio) server's bridge now takes its items from the protocol's
+ `getMetaItems` when the host can supply it, and keeps the #6504 completeness
+ verdict by asking `listDiagnosed` for it alongside. A host assembled without the
+ metadata protocol reads exactly as before, and a merged read that throws does not
+ fall back to the un-merged listing.
+- f9d7acf: docs(mcp): rewrite the published README to the shipped host-extension surface (#9579)
+
+ `packages/mcp/README.md` is in the package's `files` array with `private` unset,
+ so it is the page npm renders. It told the reader to extend the server
+ imperatively at six call sites:
+
+ ```ts
+ kernel.getService('mcp').registerTool(calculateRevenueTool);
+ kernel.getService('mcp').registerResource({ … });
+ kernel.getService('mcp').registerPrompt({ … });
+ ```
+
+ `MCPServerRuntime` has never had any of those members. Measured against the
+ built `dist/index.d.ts`, a consumer who copies those lines gets three
+ `TS2339 Property … does not exist on type 'MCPServerRuntime'`. The receiver is a
+ local variable, so `check:published-readme-exports` is structurally blind to
+ them — both of its halves key on a name the fence *imported*, and this one is
+ neither imported nor a bare identifier.
+
+ Ruled 2026-08-18: **document the shipped surface; do not grow the API to match
+ the docs.** So the imperative narrative is gone and the page now documents what
+ actually ships — the bridge methods (`bridgeTools`, `bridgeDataTools`,
+ `bridgeResources`, `bridgePrompts`), `handleHttpRequest` / `renderSkill`, and the
+ exported `registerObjectTools` / `registerActionTools` / `registerSkillPrompts`
+ helpers driving an `McpServer`. Every row is probed against the built type entry
+ the `exports` map resolves, and the page's one host-extension example compiles
+ clean against it.
+
+ Neighbouring fabrications the audit turned up, all corrected in the same pass —
+ each of them was reachable only through prose or an unimported receiver, which is
+ why nothing had read them:
+
+ - **A tool family that does not exist.** The page listed
+ `objectstack_find` / `objectstack_findOne` / `objectstack_create` /
+ `objectstack_update` / `objectstack_delete` / `objectstack_describeObject` /
+ `objectstack_listObjects` / `objectstack_listFields` as "auto-registered". No
+ such tool name occurs anywhere in the repo. The real names are the
+ `list_objects` … `run_action` set the page listed separately, one section down.
+ - **`aggregate_records` was missing** from the list that *was* correct, along
+ with the fact that it registers only when the bridge implements `aggregate`.
+ - **Resource URIs were wrong in both directions.** The page taught
+ `objectstack://objects/{name}/records` (no such resource) and
+ `objectstack://objects/{name}/{id}` (real shape is
+ `…/{name}/records/{id}`), and omitted `objectstack://objects` and
+ `objectstack://metadata/types` entirely.
+ - **The advertised capability block was invented.** It claimed
+ `tools.listChanged`, `resources.subscribe`, `resources.listChanged`,
+ `prompts.listChanged` and `experimental.streaming`. The server hand-declares
+ only `logging`; everything else is *derived* from what was actually registered,
+ which is the ADR-0076 D12 contract the README was contradicting. The
+ "Streaming Support" feature bullet and the streaming-resource example went with
+ it — neither names anything that ships.
+ - **The stdio transport could not be started by following the page.** Neither
+ `OS_MCP_STDIO_ENABLED` nor `OS_MCP_STDIO_API_KEY` was documented, and stdio
+ auto-start refuses to boot without the key (ADR-0101, fail-closed). The three
+ client config blocks now carry both. The Debugging section also taught
+ `OS_MCP_SERVER_ENABLED=true` as the stdio switch, which is the deprecated path
+ that logs a warning.
+ - **A broken relative link.** `../../spec/src/ai/` resolves above the repo root
+ from `packages/mcp/`; the target is `../spec/src/ai/`.
+
+ Docs only — no runtime code changed, and no API was added. `registerTool` /
+ `registerResource` / `registerPrompt` remain unbuilt by ruling; a future
+ imperative API is its own card on measured pull.
+- cd455c8: docs: four published READMEs stop documenting symbols and call sites that do not exist (#9544)
+
+ All four packages ship `README.md` in their `files` array with `private` unset, so these
+ are the pages npm renders. Each finding was re-measured against the **built `.d.ts`**, not
+ against source, because that is what a consumer resolves through the `exports` map.
+
+ - **`@objectstack/driver-sql`** — `import type { IDriver } from '@objectstack/spec'` named
+ a type that exists **nowhere in the repository** (0 hits across every package's `src`
+ and `dist`). The real contract is `IDataDriver` on `@objectstack/spec/contracts` — the
+ one `SqlDriver` actually declares (`export class SqlDriver implements IDataDriver`). The
+ adjacent operation list was corrected too: the method is `create`, not `insert`.
+
+ - **`@objectstack/mcp`** — `DriverSql` has never existed (the export is `SqlDriver`), and
+ the README then called `DriverSql.configure({...})` on it. Renaming alone would have
+ been wrong twice over: `SqlDriver` has **no static `configure` either**, and `driver:`
+ is not a key of `defineStack` at all. The example now declares a datasource the way the
+ shipped templates do. `MCPServerPlugin.configure({...})` — five call sites — becomes
+ `new MCPServerPlugin({...})`, the form the class's own JSDoc and every in-repo caller
+ use. The documented options block claimed `serverName`, `autoRegisterTools`,
+ `autoExposeObjects`, `enableStreaming`, `port` and `debug`; the real
+ `MCPServerPluginOptions` is `name`, `version`, `transport`, `autoStart`, `instructions`,
+ and the env switches are named instead.
+
+ - **`@objectstack/objectql`** — `registerObject` is an **instance** method, so
+ `SchemaRegistry.registerObject(...)` on the class could never run. The example now
+ reaches it through the engine's registry and states the real parameter order
+ (`schema, packageId, namespace?`).
+
+ - **`@objectstack/spec`** — the protocol package's own front page imported
+ `MCPServerConfigSchema` from `@objectstack/spec/ai`, which exports `MCPServerRefSchema`.
+ A rename by itself would have swapped a broken import for a broken **parse**: the
+ documented payload was built for a schema that does not exist, and
+ `MCPServerRefSchema.safeParse` rejects it (`transport` is an enum of
+ `stdio | http | websocket`, not an object, and `endpoint` is required and was absent).
+ The example is now a payload that parses green, and the page says plainly that tools,
+ resources and prompts are derived from metadata at runtime rather than authored there.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2d0af57]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/formula@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/mcp/package.json b/packages/mcp/package.json
index feae2eee55..ba8086ece2 100644
--- a/packages/mcp/package.json
+++ b/packages/mcp/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/mcp",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "ObjectStack as an MCP server — exposes your app's objects (and AI tools) over the Model Context Protocol (stdio + Streamable HTTP)",
"type": "module",
diff --git a/packages/metadata-core/CHANGELOG.md b/packages/metadata-core/CHANGELOG.md
index 9416141b42..4b1de51e9b 100644
--- a/packages/metadata-core/CHANGELOG.md
+++ b/packages/metadata-core/CHANGELOG.md
@@ -1,5 +1,296 @@
# @objectstack/metadata-core
+## 17.1.0
+
+### Patch Changes
+
+- b6c7690: fix(rest): org-overridable metadata is served back by every `/meta` read door, not just persisted (#9454)
+
+
+
+ A runtime `PUT` of an org-overridable metadata type — `view`, `dashboard`,
+ `report`, `translation`, `email_template` — answered **200** with a receipt
+ reporting `state: 'active'` plus a version and sequence number, **persisted the
+ row with its `organization_id`**, and was then served back by **nothing**: the
+ direct `GET` answered 404, the scoped listing was unchanged, the unfiltered
+ listing was missing it, and the browser rendered an empty view or "Dashboard Not
+ Found". The platform reported success in the same breath as not delivering the
+ work, which is declared ≠ enforced in the direction hardest for an author to
+ notice — the write path says everything worked.
+
+ **The write door was correct as-is.** The row really is persisted, so the
+ receipt is truthful; this was persisted-but-not-served, never a silent write
+ no-op. **The overlay-resolution layer was correct too**, and type-agnostic:
+ `getMetaItem` resolves `(orgId ? findOverlay(orgId) : undefined) ??
+ findOverlay(null)`, `getMetaItems` unions both scopes under org-wins precedence,
+ and `getMetaItemLayered` even reports `overlayScope`. The defect was that the
+ REST read doors **never stated the scope**, so every one of them asked for the
+ env-wide partition and the org partition was never consulted.
+
+ **The repair is one registry-derived predicate, threaded at the read doors.**
+ `organizationIdForMetaRead` joins `organizationIdForMetaWrite` in
+ `metadata-core`, deriving from the same `allowOrgOverride` registry flag, so
+ read scope and write scope cannot drift and a registry entry flipping the flag
+ moves both doors together. It is threaded through the **already-memoised**
+ `resolveExecCtx`, so no new per-request organization resolution is introduced.
+
+ ⛔ **Not a bare `ctx?.tenantId` at each site**, and the reason is measurable
+ rather than stylistic: deployments predating the #6190 ruling hold **phantom
+ org-scoped rows for types the registry declares non-overridable** (the runtime
+ used to stamp `organization_id` on every type). Boot hydration deliberately
+ walks past those rows, so they are dead. A read door naming the org for *every*
+ type would resolve them again — serving, on the read side, a document that
+ vanishes at the next restart.
+
+ **`getMetaItemCached` gains an `organizationId` member** — it was the only meta
+ read verb that could not express one, having hard-coded a two-key delegation to
+ `getMetaItem`. The organization is also folded into its **ETag**. The mechanism
+ differs from `locale` and the difference is stated rather than glossed: `locale`
+ is invisible to the hash (the body is translated after the validator runs), so
+ folding it in was the only way it could vary the validator at all, whereas the
+ org-resolved document *is* the thing hashed. No cache leak is claimed — the
+ directive is `private, no-cache` and there is no server-side cache entry keyed by
+ type+name. It is folded in because that makes scope a **declared** property of
+ the validator instead of an emergent property of the body.
+
+ **Both REST branches are fixed, which is the half-fix this card could easily
+ have shipped instead.** `view` and `dashboard` share one mechanism but reach it
+ through two different arms: `view` takes the cached arm (`getMetaItemCached`),
+ while `dashboard` bypasses the cache via `isDashboardType` and takes the
+ uncached arm. Both omitted the org, so a fix applied to one arm would have
+ fixed exactly one type while the receipt kept claiming success for the other.
+ The scope is now resolved **above** the fork, so the two arms cannot disagree.
+
+ The regression proof drives real REST routes against a real protocol over a stub
+ engine — write-then-read agreement on **one boot**, for all five types, through
+ both arms. Its most important assertions are the ones that do **not** merely
+ check the item comes back: an org-less caller and a **second organization** must
+ each be refused it. An org-blind overlay fallback would satisfy every other
+ assertion in the file while matching an arbitrary tenant's row.
+- 845e164: fix(metadata-protocol): a package publish refused by the namespace-prefix rule now leaves an audit row per violation (#8595)
+
+ `publishPackageDrafts` refuses a whole batch pre-flight when an object draft's
+ name is missing its package namespace prefix (ADR-0028). That refusal returns
+ ABOVE the batch's `engine.transaction()`, so it reached neither the post-commit
+ `allowed` rows nor the rollback handler's `batch_aborted` row: it wrote nothing
+ to `sys_metadata_audit` at all. The compliance consequence is the defect — a
+ package rejected for a bad object name was **indistinguishable in the trail from
+ a package nobody ever pressed Publish on**, so a compliance query could not tell
+ a refused publish from one that never happened.
+
+ Each violation now leaves its own `publish` / `denied` row keyed on the
+ offending draft's `(type, name)` — the tuple `auditMetaItem` reads, so the
+ refusal is visible on that item's own audit-log tab via
+ `GET /api/v1/meta/:type/:name/audit`. The row carries the violated rule
+ (`namespace_prefix`) as its `code`, and the rule's actionable message as `note`.
+ Rows are keyed on the draft's own organization scope, matching the promoted
+ rows: an env-wide draft audits env-wide even when the publishing session carries
+ an active org.
+
+ One row per violation rather than one per batch: a pre-flight refusal names N
+ violating items and no single causal one, so a batch-level row would have had to
+ mint a synthetic identity — exactly what the `batch_aborted` row declines to do
+ for its own unattributable case.
+- 1a7f907: fix(metadata): a package publish refuses a draft stored under a non-canonical metadata type, and the ADR-0010 audit writer asserts its `type` instead of folding it (#8908)
+
+
+
+ **Two tightenings, one card, because they are the same defect at two layers.**
+
+ `publishPackageDrafts` reads `sys_metadata` rows **at rest**, so #7894's `/meta`
+ boundary fold never reached it. `promoteDraftForPublish` folds the stored
+ spelling through `PLURAL_TO_SINGULAR` — the *manifest-collection* map, which
+ legitimately omits types that are not stack collections. For those the fold is a
+ **no-op**: the lookup key equals the stored spelling, the draft resolves, and the
+ publish mints an ACTIVE row in the namespace `PUT /meta/field/…` answers
+ 403 NOT_OVERRIDABLE for. Measured on the card with the real repository over a
+ stub engine:
+
+ ```
+ publishPackageDrafts({ packageId: 'app.demo' })
+ → { success: true, publishedCount: 1, published: [{ type: 'fields', name: 'legacy_field' }] }
+ active row: { type: 'fields', name: 'legacy_field', package_id: 'app.demo' }
+ audit row: { type: 'fields', name: 'legacy_field', outcome: 'allowed', code: 'ok' }
+ ```
+
+ Every registry read and every compliance query on `field` misses an item the
+ platform just reported as published — the #4432 shadowing shape, minted at
+ publish time instead of at the URL, and the last route by which a pre-#7894 row
+ could be re-promoted rather than migrated.
+
+ **1. The publish refuses it, at the pre-flight, batch-atomically.** Same shape as
+ the ADR-0028 namespace-prefix gate that already stands there: found before
+ anything is promoted, failing the whole batch (`publishedCount: 0`,
+ `published: []`) rather than publishing the healthy siblings around it, with one
+ audit row per violation. The refusal names the row, names the canonical type, and
+ states the re-author path; `failed[].code` is the new
+ `STORED_TYPE_NOT_CANONICAL`, and the audit column's spelling is
+ `stored_type_not_canonical`.
+
+ The rule is **derived, not a list**: a spelling the platform's URL/registry map
+ folds elsewhere *and* the manifest map leaves unchanged. Against the real maps
+ that is **six** spellings — `fields`, `seeds`, `external_catalogs`,
+ `externalCatalogs`, `translations`, `email_templates` — where the card named
+ four; the last two would have been missing from any hand-written list, and a
+ newly declared type that never reaches the manifest map is covered on the day it
+ is declared. A manifest-**present** plural (`objects`) is deliberately *not* in
+ the class: it is already fail-closed at the promote (`NO_DRAFT`, batch aborted)
+ and keeps that verdict.
+
+ ⛔ Deliberately **not** included: migrating the row (a `_migrate-stored` /
+ boot-reconciliation conversion). That was the other option on the card and is
+ explicitly unruled — it stays available as a follow-up with its own appetite.
+
+ **2. `recordMetadataAudit` refuses a non-canonical `type` (`AUDIT_TYPE_NOT_CANONICAL`)
+ instead of folding it.** The writer used to open with
+ `type: PLURAL_TO_SINGULAR[entry.type] ?? entry.type` — a lenient consumer, and a
+ **tolerant-and-incomplete** one: the fold read the same manifest map, so the
+ compliance trail came out canonical for the 29 types that never needed it and
+ non-canonical for exactly the ones that did. Ruled the same direction as the
+ refusal above: **fold at the boundary, assert at the writer.** Every call site
+ that builds a row out of an at-rest `type` — all of them on
+ `publishPackageDrafts` — now folds with `canonicalMetaType`; the `/meta` routes
+ were already canonical by the time they got there. The throw sits **outside** the
+ writer's best-effort `try`, because inside it the method's own `catch` would
+ degrade the assert into a `console.warn`.
+
+ The assert cannot refuse a canonical type (no canonical spelling folds
+ elsewhere — 33 of 33, measured) nor a plugin-registered or otherwise
+ unrecognised kind (`canonicalMetaType` is the identity for anything the static
+ map does not carry), so it narrows the accept set without closing it.
+
+ **Reachability was enumerated before the assert landed**, as the ruling required:
+ `recordMetadataAudit` is private to `protocol.ts` with 11 call sites, `sys_metadata`
+ rows have exactly one producer in the repository (`saveMetaItem` → `repo.put`,
+ post-fold), and no current write path can mint a non-canonical stored type. The
+ only non-canonical types that ever reached an audit write came from the batch
+ publish's at-rest rows, which is what the boundary folds now cover.
+
+ Also fixed, as a consequence of that fold rather than as a separate change: on
+ the batch route `getEffectiveLock`'s overlay limb was queried with the raw stored
+ spelling, so an ADR-0010 `_lock` carried by the canonical active row was looked
+ up under a `type` no row has and came back `'none'` — the verdict "the author
+ declared no protection". That is the batch twin of the hole #8769 closed on
+ `publishMetaItem`.
+- 7fc01db: REST `/meta` write doors now carry the caller's organization, so audit rows are no longer stamped environment-wide
+
+ `PUT /meta/:type/:name` (both arities), `DELETE /meta/:type/:name`,
+ `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` passed no
+ organization, so every `sys_metadata_audit` row a REST-authored metadata write produced was
+ stamped `organization_id: null`. Composed with the scoped audit read shipped alongside it —
+ which returns own-org rows **plus** environment-wide ones, a limb that is required rather
+ than optional — that left every REST-authored audit row readable by every tenant, carrying
+ its `actor`, `note`, `lock_state` and `request_id`. The read side could not close this: the
+ rows were genuinely unscoped, so no filter could separate them.
+
+ The organization is taken from the execution context these doors already resolve, and is
+ threaded through `organizationIdForMetaWrite` — the same registry-derived predicate the
+ runtime `/metadata` dispatcher uses. Types the registry declares `allowOrgOverride: true`
+ (`view`, `dashboard`, `report`, `translation`, `email_template`) now scope both the overlay
+ row and its audit row to the caller's organization; every other type continues to write
+ environment-wide, because its write genuinely is environment-wide and the protocol refuses
+ an org-scoped write for it. `null` is now reserved for writes that really are
+ environment-wide.
+
+ Two behaviour changes ride along, both required for the fix to be usable rather than
+ separate improvements: `publish` and `rollback` resolve their row through the organization,
+ so scoping the save without scoping them would have broken the draft → publish loop; and
+ `GET /meta/:type/:name/published` is now organization-scoped (organization-first, then
+ environment-wide), without which it would answer 404 for an item the same caller had just
+ published through the same transport.
+
+ `organizationIdForMetaWrite` / `declaresOrgOverride` moved from `@objectstack/runtime` into
+ `@objectstack/metadata-core` so both doors share one implementation — `@objectstack/rest`
+ cannot import from `runtime`, which depends on it. Runtime behaviour is unchanged.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [19539b4]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json
index ae9c00e8cb..8ed015443a 100644
--- a/packages/metadata-core/package.json
+++ b/packages/metadata-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/metadata-core",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Metadata Repository contracts: types, canonicalization, errors, interface (ADR-0008).",
"type": "module",
diff --git a/packages/metadata-fs/CHANGELOG.md b/packages/metadata-fs/CHANGELOG.md
index c9da0f14ac..7bed9aae23 100644
--- a/packages/metadata-fs/CHANGELOG.md
+++ b/packages/metadata-fs/CHANGELOG.md
@@ -1,5 +1,65 @@
# @objectstack/metadata-fs
+## 17.1.0
+
+### Patch Changes
+
+- bd2fc8b: fix(metadata-fs): an external write reaches subscribers even when the watcher's single delivery attempt is lost — content-keyed reconciliation behind the poll (#9339)
+
+ `FileSystemRepository`'s watcher gave an externally-written file **exactly one**
+ chance to be noticed, and losing it was permanent and silent. Under
+ `usePolling`, chokidar re-reads a directory only when its stat *strictly*
+ advances; an external write advances the type directory's mtime once, so poll
+ #2..#N compare an unchanged stat and can never rediscover the file. Measured on
+ #9339 with a fault-injection harness: with that single read suppressed, fifteen
+ further poll ticks never find the file — a 20s deadline and a 200s deadline buy
+ the same one attempt. That is the structural reason behind #7282's empirical
+ finding that the event is *"never delivered, not slow"*, and why widening the
+ deadline (#7208) and lowering `interval` were both spent before they were tried.
+
+ **At least six independent one-shot gates sit on that attempt**, spanning three
+ layers — the kernel timestamp (the directory mtime does not strictly advance),
+ chokidar's readdir throttle and readdir snapshot, and chokidar's emit gates
+ (`_throttle('add')`, a stale `_pendingWrites` entry, the `awaitWriteFinish`
+ ENOENT early return). Each produces a byte-identical observable: no event, ever,
+ for that path. They are indistinguishable at the point of failure, which is why
+ #7282's close — picked from that family — covered one member and reopened.
+
+ **The fix does not name a member.** A bounded, content-keyed reconciliation
+ sweep runs alongside the watcher and compares what is on disk against `heads`,
+ the index that already defines what the repository believes it holds, publishing
+ any divergence through the *same* handler the watcher feeds. Its only premise is
+ that the bytes on disk stopped matching the index, so it is robust across all six
+ by construction — and equally across a seventh nobody has found.
+
+ - **Cadence** — one pass over `//*.json` every 2s (twice the poll
+ interval), the same walk `start()` already performs once. Sweeps are chained
+ rather than intervalled, so they can never overlap or stack behind a slow
+ disk; the timer is `unref`ed and is retired by `close()`; and it is armed only
+ alongside the watcher, so a `disableWatch` repository pays nothing.
+ - **Exactly-once is preserved.** Suppression stays content-keyed (`#7335`): the
+ sweep republishes nothing the watcher already delivered, and recognises this
+ repository's own `put()` by content rather than by a clock.
+ - **Events are indistinguishable from the fast path** — same `op`,
+ `parentHash`, `source: 'fs'` and actor, because they are produced by the same
+ code. A subscriber cannot be made to care which path noticed.
+ - **A recovered path is re-armed** with the watcher through the seam `put()`
+ already uses, so a loss upstream of chokidar's `_handleFile` does not leave
+ the file dependent on the sweep forever.
+ - `put()`'s existing direct registration (#7336) is unchanged, as are
+ `usePolling`, `interval`, and `awaitWriteFinish`.
+
+ ⚠️ **Bound on the claim.** The six gates are *forced fault injections*, not the
+ CI mechanism, which was never identified and may be a seventh. What is measured
+ is that the fix converts **six of six** forced one-shot gates from permanent
+ loss to delivery (3/3 runs each), where all six returned an empty event list
+ before it. That is not the same statement as "the flake is fixed".
+- Updated dependencies [b6c7690]
+- Updated dependencies [845e164]
+- Updated dependencies [1a7f907]
+- Updated dependencies [7fc01db]
+ - @objectstack/metadata-core@17.1.0
+
## 17.0.0
### Patch Changes
diff --git a/packages/metadata-fs/package.json b/packages/metadata-fs/package.json
index f48dbcf9b0..70c50c2b51 100644
--- a/packages/metadata-fs/package.json
+++ b/packages/metadata-fs/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/metadata-fs",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "FileSystemRepository: Node-only Repository implementation backed by JSON files and a JSONL change log (ADR-0008).",
"type": "module",
diff --git a/packages/metadata-protocol/CHANGELOG.md b/packages/metadata-protocol/CHANGELOG.md
index 90cb919bb7..e1dcf951bc 100644
--- a/packages/metadata-protocol/CHANGELOG.md
+++ b/packages/metadata-protocol/CHANGELOG.md
@@ -1,5 +1,1695 @@
# @objectstack/metadata-protocol
+## 17.1.0
+
+### Minor Changes
+
+- e374b4d: 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.
+- 40d5b2d: `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.
+- 13d7864: Dashboard writes are now judged by `validateWidgetBindings` at the runtime publish gate (#7529). A dashboard widget bound to a dataset that resolves to nothing — previously a `200` on both save and publish, failing only as a runtime error on the live board — is refused at **publish** with a located 422 (`INVALID_METADATA`, the offending key path named). Drafts are unaffected: a draft may still hold a forward reference to a dataset not yet authored, and only the draft→active promotion runs the gate.
+
+ Because rule surfaces are registered per-rule, all six of the rule's error-tier findings now gate a dashboard publish as one reference-integrity class: `widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`, `chart-field-unknown`, `widget-legacy-analytics-unrenderable`, `dashboard-filter-field-unknown`. Warning-tier findings (`table-count-only`, `chart-config-missing`, …) ride the non-blocking `advisories` channel on the save response. Config-authored stacks are unaffected — `os validate` / `os build` / `os lint` already ran this rule; the newly gated population is exactly the `sys_metadata` overlay writes (Studio / REST `/meta` / MCP) that previously bypassed it.
+
+ The per-write snapshot (`RuntimeStackContext`) now carries the live `datasets` collection so bindings resolve against the real dataset universe — without it every legitimate board would read as dangling. Existing stored rows are untouched (the gate blocks new publishes only), and `OS_ALLOW_UNLINTED_METADATA_WRITES=1` remains the migration-window escape hatch.
+- a8189ae: feat(objectql,metadata-protocol): refuse a dotted filter key whose head is a relation, a formula, or a plain scalar — at both doors (#8371)
+
+
+
+ **BREAKING** accept-set narrowing on the FILTER axis, landing after the v17.0.0
+ cut (the lockstep launch-window convention ships it as `minor`; the migration
+ prescription is registered under protocol major 18, where `objectstack migrate
+ meta` users will look).
+
+ FILTER was the last of the four query axes with no verdict for a dotted name:
+ SORT refuses it (#4256), PROJECTION refuses it at both doors (#7589), while
+ `where: { 'project_id.name': 'Apollo' }` cleared the unknown-field check on its
+ head segment and answered `200` with zero rows. Measured across all three
+ drivers before ruling (#8371): relation-head, formula-head, system-column-head
+ and plain-scalar-head dotted filters return zero rows on `driver-memory`,
+ `driver-sql` and `driver-mongodb` alike — a lookup stores the related record's
+ scalar id, so there is no working capability for this refusal to remove; every
+ answer was a silent empty list indistinguishable from an empty table, and the
+ virtual case answered one unserviceable intent two ways by spelling
+ (`{is_open: true}` refused since #8296, `{'is_open.x': true}` not).
+
+ **What is refused:** a dotted filter key whose head field is a relation
+ (`lookup`/`master_detail`/`user`/`tree`), a virtual `formula`, or a plain
+ scalar — `400 INVALID_FIELD`, naming the whole offending key, at both the REST
+ ingress (`assertFilterFieldsExist`) and the engine's own filter seam
+ (`assertFilterIsMaterializable`, reached by saved reports, flows and dashboard
+ widgets whose filters never pass the ingress). Both doors judge the head by the
+ shared `@objectstack/spec/data` classification (`classifyDottedFilterHead`,
+ new export), so they cannot drift apart. Precedence mirrors the sort axis:
+ `unknown` > `dotted` > unmaterializable.
+
+ **What stays accepted:** a dotted path into a structured/JSON head
+ (`{'address.city': 'Beijing'}`) — deliberately unjudged per the ruling, since
+ it genuinely works on two of three backends; array-valued and file heads, for
+ the same reason; the nested-relation OBJECT form `{ owner: { region: 'NA' } }`;
+ and every undotted spelling, byte-identically.
+
+ ## FROM → TO
+
+ ```ts
+ // before — 200, zero rows, indistinguishable from an empty table
+ await engine.find('task', { where: { 'project_id.name': 'Apollo' } });
+
+ // after — 400 INVALID_FIELD naming 'project_id.name', with the remedy:
+ // denormalise the value onto a stored field of the queried object and
+ // filter that (or, to test the relation itself, filter the head field):
+ await engine.find('task', { where: { project_id: apolloId } });
+ ```
+
+ There is deliberately no automatic rewrite: the platform cannot invent the
+ stored column the remedy prescribes, and it must not join or post-filter
+ instead — the drivers have already applied `limit`/`offset`, so a post-hoc
+ predicate would filter an arbitrary page.
+- b69d0f5: fix(metadata): `PUT /meta/:type` refuses a type name the platform does not have, instead of minting a namespace for it (#8421)
+
+
+
+
+ **BREAKING** accept-set narrowing on a published HTTP surface, landing after the
+ v17.0.0 cut (the lockstep launch-window convention ships it as `minor`). A write
+ that answered `200 {"success":true}` now answers `400 INVALID_REQUEST`:
+
+ ```
+ PUT /api/v1/meta/fieldz/showcase_task.title
+ before → 200, sys_metadata row persisted with type='fieldz'
+ after → 400 INVALID_REQUEST, nothing persisted
+ ```
+
+ `fieldz` — or any typo — was neither a declared metadata type nor a known plural
+ spelling of one, so the boundary classified it as PLUGIN-registered, which every
+ authorization gate is permissive toward by construction. The row was persisted
+ under a type nothing reads and nothing serves, and the caller was told it had
+ succeeded. That silence is the real cost: a metadata-type typo, from a human or
+ from generated code, produced `success: true` and no indication the type is not
+ real.
+
+ **Why this is only now safe to refuse.** #7894 closed the sibling case (a plural
+ spelling of a type the platform DECLARES) and left this one open on purpose: a
+ static predicate cannot tell `fieldz` from a plugin kind, and the live-registry
+ alternative was measured to be worse than the defect — the live type set is
+ ITEM-POPULATED, so it omits every legitimate kind that has no items yet, which
+ is the state each kind is in immediately before its first create. What changed
+ is the platform, not the boundary's information: #8586 retired
+ `MetadataPluginConfig.additionalTypes` and with it the last channel by which a
+ plugin could DECLARE a metadata kind, so an unrecognised name can no longer be a
+ declaration this refusal has not heard about (maintainer ruling 2026-08-14).
+
+ **What still passes, pinned in both directions.** Every declared type in
+ `DEFAULT_METADATA_TYPE_REGISTRY`, in canonical and REST-plural spelling; every
+ manifest spelling and the singular each folds to; and the six plugin kinds that
+ have no static registry entry at all — `theme`, `webhook`, `connector`,
+ `sharing_rule`, `analytics_cube`, `rag_pipeline`. `PUT /meta/theme/dark` on a
+ deployment with zero themes is explicitly covered, because that first create is
+ exactly what a live-registry check would have broken.
+
+ **The refusal is scoped to the door that mints.** Reads still ANSWER: a running
+ kernel legitimately holds live type keys the static contract does not — `data`,
+ `kind` and `package` all enter the registry during an ordinary `registerApp`,
+ and `GET /api/v1/meta/types` lists that live set — so refusing unrecognised
+ names on the read path would answer 400 for types the same service advertises.
+ `DELETE` is untouched for the mirror-image reason: rows minted under an
+ unrecognised type before this change are real, nothing rewrites them on upgrade,
+ and refusing their deletion would turn the accumulation this fixes into an
+ accumulation nobody can clear.
+
+ **…but one published ADVERTISEMENT narrows with it, and that is a second
+ behaviour change worth reading on its own.** `GET /api/v1/meta/types` keeps
+ listing every live type, and every entry keeps every field — what changes is the
+ VALUE of one boolean:
+
+ ```
+ GET /api/v1/meta/types → entries[] where type ∈ {policy, data, package, kind}
+ before → allowRuntimeCreate: true
+ after → allowRuntimeCreate: false
+ ```
+
+ The listing synthesised `allowRuntimeCreate: true` for every live type with no
+ static registry entry, on the same expired premise as the write door: a name the
+ registry does not carry might be a kind some plugin declared. It now derives that
+ flag from the SAME predicate the mint door enforces, so the two endpoints agree
+ by construction instead of via two rules maintained apart. Nothing ever honoured
+ a runtime create on those four — they are internal bookkeeping (seed datasets,
+ package rows, kind descriptors) — so the advertisement was a promise the platform
+ did not keep, which is the same defect this card is about, relocated to the read
+ door. Direct precedent: `api` declared `allowRuntimeCreate: true`, the runtime
+ never honoured it, and the 2026-08-07 ruling removed the declaration rather than
+ converging the read path onto it.
+
+ ⛔ The six plugin kinds with no registry entry — `theme`, `webhook`, `connector`,
+ `sharing_rule`, `analytics_cube`, `rag_pipeline` — are **not** affected: they are
+ in the static spelling contract, stay advertised `allowRuntimeCreate: true`, and
+ stay mintable. A UI reading this field (Setup → Metadata, the Studio designers)
+ therefore loses create affordances on exactly the four types whose creates were
+ already refused, and keeps them everywhere else.
+
+ **The premise behind both halves is a CURRENT posture, not a closed door.**
+ Maintainer ruling, 2026-08-15, verbatim and untranslated:
+ 暂时不考虑让插件申明新的元数据类型 — plugins do not declare new metadata types
+ *for now*. That word is recorded deliberately: plugin-declared kinds were
+ considered and deferred, not ruled out. If they are ever wanted, the two sites
+ that encode the deferral name it and its date in place —
+ `getMetaTypes()`'s synthesis and `isRuntimeCreateAllowed` in
+ `@objectstack/metadata-protocol` — so the decision is findable rather than
+ re-derived from the code's silence.
+
+ **Two shapes reaching the mint door are exempt, and each is a fact about the
+ request rather than a claim the caller makes.**
+
+ 1. *The COMPOUND arity carries an OBJECT name in the `:type` segment.*
+ `PUT /api/v1/meta/lead/views/all_leads` is `type='lead'`,
+ `name='views/all_leads'` — one operation reaching one save, the shape both
+ the runtime dispatcher and the REST route document verbatim. `lead` is an
+ object, i.e. runtime data no static contract can enumerate, so a type verdict
+ applied there would refuse every object name that is not coincidentally a
+ metadata type. The ruling is about metadata TYPE names like `fieldz`.
+ ⚠️ Residue, stated rather than hidden: `PUT /meta/fieldz/a/b` is therefore
+ still accepted, because at that arity `fieldz` is a claim about an object and
+ the only way to check it is the live-registry lookup this card ruled out.
+ 2. *A namespace that already exists is not being minted.* `duplicatePackage`
+ re-saves every row of a package under a new name, taking each type from the
+ stored row — measured: a package holding one pre-existing residue row
+ answered `{success: false, copiedCount: 0, failedCount: 1}`, i.e. could not
+ be duplicated at all. That contradicts the `DELETE` reasoning above, so the
+ store (never the request) exempts a type that already has rows. The probe
+ runs only once the refusal has already fired, and a store that cannot answer
+ refuses — a fresh deployment has no residue to protect.
+ `migrate meta --stored` was read as a third victim and measured NOT to be
+ one: an unrecognised type has no manifest collection, hence no ADR-0087
+ chain, hence no notice, so such a row is reported `canonical` and the mint
+ door is never reached.
+
+ **What breaks.** A caller creating metadata at runtime, at the simple arity,
+ under a type name that is in neither half of the static spelling contract and
+ has no rows already. That set is **not** empty in this repo — measured on
+ `objectql`, `runtime` and `rest`, three in-tree fixtures minted `trigger` (a kind
+ ADR-0088 retired outright), `policy`, and a synthetic `my_plugin_kind`. All three
+ are corrected here rather than exempted, and each for its own reason: the
+ `trigger` specimens were debt independent of any ruling (a retired kind cannot
+ demonstrate a live tier, and they were green only through the hole this card
+ closes), `policy` becomes a refusal case of its own, and #7894's control keeps
+ its `metaUrlSpellingRefusal` claim while its boundary expectation follows the
+ narrowing. An out-of-tree plugin that made its kind live by registering an item
+ of it, and then accepted runtime writes to that kind through `/meta`, needs its
+ spelling in the contract; there is no declared-kind channel to register one
+ through today — that is the trade #8586's retirement made, and the `暂时` above
+ is what makes it revisitable.
+
+ `@objectstack/spec` gains one export, `unrecognisedMetaTypeRefusal`, alongside
+ the #7894 verdict it deliberately does not merge with: one says *you spelled a
+ declared type wrongly* and can name the replacement, the other says *there is no
+ such type* and never guesses. The residue pin #7894 left behind
+ (`metadata-url-spelling.test.ts`, the case that asserted `fieldz` was refused by
+ nobody) is **flipped, not deleted**. ⚠️ #7894's positive control keeps its own
+ claim intact — `metaUrlSpellingRefusal` still cannot refuse a kind that is a
+ misspelling of nothing, which is what makes that control true by construction —
+ but the BOUNDARY it drives now refuses six of the twelve names it exercises,
+ and that case says so in place rather than leaving it to inference.
+- 09a6eee: The publish door now reports the runtime authoring gate's advisory findings (#9176). `POST /api/v1/meta/:type/:name/publish` carries the same optional, omitted-when-empty `advisories` key the save door already carries (#4463 D1/D3, #4717): `PublishMetaItemResponseSchema` declares it (`RuntimeAuthoringIssueSchema` elements, declared once in `@objectstack/spec`), and `publishMetaItem` attaches the findings the promotion-time gate run returns instead of discarding them. A clean publish's response bytes are unchanged — the key is present only when at least one `warning`/`info` finding was raised; `error` findings still refuse the promotion as the 422 envelope. This matters most for Studio / MCP / AI authors, whose designer takes draft-then-publish on every edit and has no CLI to surface the same findings.
+- a4c11ad: Derive the metadata reference graph from the type schemas instead of curating it by hand
+
+ `GET /api/v1/meta/:type/:name/references` — the admin "Used by" panel, rendered
+ immediately before a rename or a delete — was driven by a hand-written table of
+ seven target types and forty dotted paths. Measured against the schemas it was
+ supposed to describe, **34 of those 40 paths named properties no metadata type
+ declares**: `app.navItems[]` / `app.tabs[]` (the schema declares `navigation`
+ and `areas`), `agent.tools[]` (removed in `@objectstack/spec` 17),
+ `permission.objects[].name` (a name-keyed record, not an array),
+ `object.fields{}.referenceTo` (the field property is `reference`),
+ `dashboard.widgets[].view`, `page.viewName`, and every path the table listed for
+ `flow`. Five of its seven target types therefore answered `{ references: [] }`
+ unconditionally, on every deployment, while appearing to be covered — and an
+ empty panel reads as "nothing depends on this, safe to delete".
+
+ Coverage is now derived at boot from `DEFAULT_METADATA_TYPE_REGISTRY` and each
+ type's Zod schema, so a newly declared metadata type arrives covered instead of
+ waiting for someone to remember it. Seventeen target types now resolve real
+ reference sites, including `permission`-to-object grants (through the record
+ key, which the old path grammar could not express), `translation`, `dataset`,
+ `action`, `report`, `doc` and `datasource`, plus flow-node references such as
+ `subflow`. References nested inside recursive containers — a view named from a
+ third-level app navigation group — are found at any depth, which no finite path
+ list could do.
+
+ No wire change: the response shape, status codes and error envelope are
+ untouched. The `path` and `kind` values now describe where the reference was
+ actually found rather than which table row matched.
+
+ Two gaps are deliberately declared rather than papered over: `external_catalog`
+ resolves no schema, so its references are not computable and it is named in the
+ derivation's `unwalkableSourceTypes` (pinned by a test, so the set cannot grow
+ silently), and reference properties whose name does not spell their target —
+ `FieldSchema.reference` is the one carried — need a producer-side annotation to
+ become derivable.
+- ad217b1: fix(metadata-protocol): compile the seed-tenancy backfill's statements for the connected dialect, so they run on MySQL (#9381)
+
+ `seed-tenancy-backfill.ts` quoted every identifier the ANSI way (`"x"`) on every
+ dialect. MySQL does not run with `ANSI_QUOTES` — measured on a live MySQL 8.0.46,
+ whose `sql_mode` is
+ `ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION`,
+ and nothing in `driver-sql` sets one — so `"x"` is a string literal there and all
+ seven statements failed with `ER_PARSE_ERROR`. The repair for #8686 therefore
+ never ran on MySQL, silently: a migration must not fail a boot, so every call site
+ turns the failure into a warning and the symptom was a skipped repair in the log
+ rather than an error.
+
+ The statements are now compiled for the driver actually connected, and the seam
+ carries the dialect with it (`resolveSeedTenancySeam` returns `{ exec, client }`;
+ `backfillSeedTenancy` takes that pair) so a caller cannot lose it. Two further
+ MySQL-only defects in the same statements, both measured on the same server, are
+ fixed with it: `last_value` is a reserved word on MySQL 8.0 and is now quoted
+ wherever it is unqualified, and the stamp's exclusion sub-SELECTs go through a
+ derived table because MySQL refuses `UPDATE t … (SELECT … FROM t)` with
+ `ER_UPDATE_TABLE_USED`. SQLite and PostgreSQL keep the exact ANSI spelling they
+ had (both re-verified live).
+
+ `resolveSeedTenancyExec` stays exported and unchanged for callers that resolve the
+ dialect themselves; `backfillSeedTenancy` now takes the seam object instead of a
+ bare exec.
+
+### Patch Changes
+
+- 5047cb8: fix(metadata-protocol): scope the metadata audit read to the caller's organization (#8747)
+
+ `ObjectStackProtocolImplementation.auditMetaItem` declared
+ `organizationId?: string | null` and never read it. The comment directly above
+ its query described the filter it would have built — "include rows for the
+ specific org AND env-wide (`organization_id IS NULL`) rows" — while the `where`
+ was exactly `{ type, name }`. The parameter was dead on the caller side too:
+ `GET /api/v1/meta/:type/:name/audit` never passed one.
+
+ The consequence was a cross-tenant disclosure, measured rather than inferred:
+ three saves of one view name under two organizations and env-wide, then one
+ `auditMetaItem({ type, name })` read, returned all three organizations' rows —
+ and with each row its `actor`, `note`, `lock_state`, `code`, `operation`,
+ `source` and `request_id`. Nothing compensated lower down. The driver's tenant
+ wall never engaged, because it is armed only from an execution context this
+ read did not pass; the security plugin's Layer 0 never engaged, because the
+ middleware short-circuits on a principal-less call long before the field gate
+ that would have carried it; and no tenancy posture would have supplied the
+ scope either. The route carries no capability gate — unlike its `PUT` twin,
+ which gates on `manage_metadata` — so the reachable cohort was any
+ authenticated principal of any tenant, on the published `meta.getAudit` SDK
+ surface.
+
+ The query now builds the described filter: rows for the caller's organization
+ plus env-wide (`organization_id IS NULL`) rows, and nothing else. The env-wide
+ limb is load-bearing rather than defensive — the REST `PUT /meta/:type/:name`
+ door passes no organization, so every row it writes is stamped
+ `organization_id: null`, and an equality-only filter would have blanked the
+ audit tab on those deployments instead of scoping it. A read that resolves no
+ organization is fail-closed onto the env-wide rows, symmetric with what an
+ org-less write produces, so omitting the parameter is no longer a skeleton key.
+
+ The REST route supplies the organization from the execution context it already
+ resolves for 40-plus handlers, adding no new organization-resolution plumbing
+ to `packages/rest`. The same call also stopped passing `environmentId`, which
+ the request type never declared and the method body never read; environment
+ scoping is unaffected, since it comes from which protocol instance is resolved
+ rather than from the request payload.
+
+ Behaviour change worth stating plainly: a caller that previously saw another
+ tenant's metadata audit rows for a same-named item no longer sees them. Own-org
+ and env-wide rows are unchanged.
+- 177442d: fix(metadata-protocol): `getMetaDiagnostics` stops publishing an unreadable metadata store as "0 problems" (#8855)
+
+
+
+ `GET /api/v1/meta/diagnostics` sweeps every metadata type and publishes four
+ numeric facts about the corpus. Its per-type read was wrapped in an **untyped**
+ `catch` that `continue`d, and the comment above it named a benign reason ("type
+ not listable in this kernel scope") that is genuinely real. The catch took
+ everything else with it — including the one error the callee exists to raise.
+
+ `getMetaItems` classifies a failed `sys_metadata` read by error **type** and
+ throws a 503 (`SERVICE_UNAVAILABLE`) for every read failure that is not "the
+ table has not been provisioned yet" — the discrimination #5532 introduced so an
+ outage would stop looking like emptiness. `getMetaDiagnostics` caught that 503
+ back into emptiness one layer up, then published the emptiness as a **number**.
+
+ **Measured on `origin/main` @ `8664a2c99` before the fix**, prediction written
+ down first and matched exactly. With an engine whose every read rejects:
+
+ ```
+ [outage: connect ECONNREFUSED 10.0.0.5:5432] RESOLVED
+ total=0 scannedTypes=26 scannedItems=0 Object.keys(stats).length=0
+ [benign: SQLITE_ERROR: no such table: sys_metadata] RESOLVED
+ total=0 scannedTypes=26 scannedItems=0 Object.keys(stats).length=26
+ ```
+
+ Two user-visible harms from one `catch`, and the benign run is what makes them
+ legible — it is the same payload minus the `stats`:
+
+ - `stats[t]` is never written, so an unreadable type is **absent** from the
+ response rather than zero. The Studio directory tile the field's own doc names
+ loses the type, byte-shaped like an environment that declares none of it.
+ - `total` counts entries that **failed validation**, and a store nobody can read
+ contributes none — so the endpoint whose whole job is reporting problems
+ answered `total: 0` at the exact moment it could read nothing. Green was the
+ failure mode.
+
+ `scannedTypes` reported the full 26 in both runs: it is computed from the intent
+ (`targetTypes.length`, fixed before the loop) and never decremented on
+ `continue`.
+
+ **The fix narrows the catch; it does not delete it.** A 503 arriving from the
+ read is rethrown **unchanged** and the sweep fails loudly (ADR-0110 D3: a miss
+ and an outage are different facts with opposite dispositions). Every other
+ failure still skips that one type, so a kernel scope that cannot enumerate one
+ type does not fail the whole governance sweep.
+
+ **No response field was added.** A per-type degradation marker would be a
+ public-surface addition, and the payload type is unchanged.
+
+ The envelope is **propagated, not rebuilt**: re-running the driver-error
+ classification here would re-wrap an already-shaped 503 in a second one and
+ displace the driver error riding as `cause` — the object `logWithheldServerFault`
+ prints for the operator. The REST boundary needs no change: the handler already
+ routes thrown errors through `handleRouteError`, which preserves the 503.
+
+ The pin carries the discriminating control in the same file: an unprovisioned
+ `sys_metadata` still answers benignly with every type present at `count: 0`, a
+ type that is genuinely not listable is still skipped at the cost of one type,
+ and a healthy store still counts its rows — while the outage cases throw. "0
+ problems" is the right answer in the benign cell, and it is exactly the answer a
+ blanket change would have kept producing in the wrong one.
+- 950bd94: perf(metadata-protocol): `diffMetaItem` stops awaiting a `historyMetaItem` read it discarded, halving the history round trips on the live diff endpoint (#8798)
+
+ `diffMetaItem` opened by awaiting a full `historyMetaItem` read, mapped it into a
+ `versions` array, and threw it away (`const _used = versions; void _used;`) while
+ the read it actually uses ran a few lines below through the engine. Every request
+ to the routed `GET /api/v1/meta/:type/:name/diff` paid for two reads of
+ `sys_metadata_history` where one is used.
+
+ Diff bodies are unchanged. The authorization gate the discarded call passed
+ through never reached this function's output: `historyMetaItem`'s early return
+ answers `{ events: [] }` for a type that is neither `isOverlayAllowed` nor
+ `isRuntimeCreateAllowed`, without throwing and without touching the engine, and
+ `diffMetaItem` reads the history rows directly — so the five gated-shut types
+ (`field`, `job`, `api`, `capability`, `agent`) were already served a full diff
+ regardless.
+
+ One behaviour change, on the outage path only. The discarded call was unguarded,
+ so an unavailable `sys_metadata_history` was fatal for gated-open types while
+ gated-shut types fell into the `try`/`catch` below it and answered an empty diff
+ — one outage, two answers, decided by an authorization gate unrelated to reading
+ history. Every type now takes the `catch`, which is the function's only stated
+ intent for that failure. Whether swallowing that outage is the right answer at
+ all is tracked in #8833.
+- 3043e98: fix(metadata-protocol): `diffMetaItem` folds its type at the request boundary and stops serving a history outage as an empty diff (#8868, #8833)
+
+ `GET /api/v1/meta/:type/:name/diff` is a routed live endpoint with a
+ caller-supplied `:type`. Two independent defects in that one method, fixed
+ together because they land in the same function.
+
+ **#8868 — the canonical fold.** `diffMetaItem` was the NINTH `/meta` entry point
+ on this URL family and the last one still deriving its type key from
+ `PLURAL_TO_SINGULAR`, the manifest-COLLECTION map that #7894 moved this boundary
+ off (#8769 routed `publishMetaItem`, #8819 routed `rollbackMetaItem`). It now
+ routes through `canonicalizeMetaRequestType`, which changes three things:
+
+ - **the answer.** For the four MANIFEST-ABSENT types — `field`, `seed`,
+ `external_catalog`, `translation`, legitimately absent from that map because
+ they are not stack collections — a plural spelling stayed plural all the way
+ into the `sys_metadata_history` query, matched no row, and the endpoint
+ answered a well-formed **empty diff** (`added: []`, `removed: []`,
+ `changed: []`) for an item that does have history. Not a refusal and not an
+ error: a silent "nothing changed". Manifest-present types (`views` → `view`)
+ folded already and were never affected.
+ - **unrecognised spellings.** The #7894 boundary refusal never ran on this verb,
+ so a spelling like `viewes` was forwarded to the plugin path instead of
+ refused. It is now `400 INVALID_REQUEST`, naming both accepted spellings. The
+ refusal stays narrow by construction: a name that reaches for no declared type
+ (a possible plugin kind) is still served.
+ - **the echoed `type`.** The response echoed the caller's spelling back while the
+ read had used a different key. It now reports the canonical spelling — the
+ precedent `saveMetaItem` and `deleteMetaItem` already set, both of which
+ `return { type: request.type }` after their own fold.
+
+ **#8833 — the swallowed outage.** The history read sat in a `try` whose `catch`
+ was empty apart from a comment. `histRows` stayed `[]` and the code below read
+ that never-filled accumulator as a real answer, so a `sys_metadata_history`
+ outage was served as a successful 200 with an empty diff — byte-identical to
+ "these two versions are the same", with no log line either. An operator
+ comparing versions before a rollback, and any SDK or agent reading this
+ endpoint, acted on "unchanged" with full confidence.
+
+ Per the maintainer ruling on #8833, the `catch` now routes through the
+ platform's existing discrimination, `rethrowUnlessMetadataStoreUnprovisioned`:
+
+ - a **genuinely absent table** — a minimal deployment that never provisioned
+ history — keeps its benign empty answer, so first boot does not explode;
+ - **every other read failure** (connection drop, timeout, permission denial,
+ query error) propagates `503 SERVICE_UNAVAILABLE`, carrying the driver error
+ as `cause`. ADR-0110 D3: a miss and an outage are different facts. This is the
+ same guard #5532 restored for `getMetaItems`.
+
+ ⚠️ **Behaviour change worth reading before upgrading.** This ADDS loudness where
+ there was none. PR #8841 had removed the last path that threw here, so as of
+ that change the outage was silent for *every* type; a diff whose history store
+ is unreachable now returns 503 where it previously returned 200 with an empty
+ diff. A diff against a deployment that never provisioned `sys_metadata_history`
+ is unaffected. No response field was added — a `historyUnavailable` key was
+ considered and declined.
+- 7b3c033: Publishing a package no longer promotes another package's draft row.
+
+ `publishPackageDrafts` lists a package's pending drafts with
+ `listDrafts({ packageId })`, but the promotion then re-resolved each row without
+ the ADR-0048 `package_id` dimension. Overlay rows are keyed by
+ `(org, type, name, package_id)` precisely so two installed packages shipping the
+ same name keep separate rows, so that lookup could not tell them apart: with two
+ packages holding drafts for the same `(type, name)`, publishing package A
+ promoted package B's unreviewed draft to active, drained B's draft row, recorded
+ it under A's ADR-0067 commit and ADR-0010 audit row, and left A's own edit still
+ pending — while answering `success: true`. Which of the two rows won was
+ driver-order dependent, so on a real driver this was a coin toss per publish.
+
+ The listed row's `package_id` is now threaded through to the promotion, which
+ resolves and drains the draft under the same key it was listed by. Publishes
+ that name no package (`publishMetaItem`) are unchanged.
+- 14935ab: 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: [] }`.
+- fd6bdf8: Declare `saveMetaItem`'s missing-item refusal as a real ADR-0112 envelope: `400` / `INVALID_REQUEST`, was an undeclared throw served as `500 INTERNAL_ERROR`.
+
+ `PUT /api/v1/meta/:type/:name` unwraps the `{ item }` / `{ metadata }` envelope shapes before calling the protocol, so a caller sending `{"item": null}` or `{"metadata": null}` reached a guard that declared neither `code` nor `status` — the only refusal in the method that did not. With no status to read, the REST boundary defaulted to a server fault, so an authoring mistake was reported as `500 INTERNAL_ERROR` and the guard's own sentence was withheld by the ADR-0112 disclosure rule and replaced with a generic fallback. Callers now receive `400` with the refusal quoted and the remedy named.
+
+ Unchanged: a missing, empty or literal-`null` request body never reached this guard and still answers `422 INVALID_METADATA` from the per-type schema parse. No new error code is introduced — `INVALID_REQUEST` is already registered to this package in the ADR-0112 ledger, and is what the structurally identical opening guard in `rollbackMetaItem` already uses.
+- 29d055b: 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.
+- 11b779e: Declare `MetadataProtocol.getMetaItemLayered` — the layered three-way diagnostic read (`GET /api/v1/meta/:type/:name/layers`) now appears on the protocol interface, typed against the already-declared `GetMetaItemLayeredRequestSchema` / `GetMetaItemLayeredResponseSchema`, so callers no longer reach the verb through `any`. Declared optional like its `getMetaItemCached` / `deleteMetaItem` siblings: a declared-surface catch-up to a shipped verb, not a new capability.
+
+ In `@objectstack/metadata-protocol`, the implementation's inline return-type annotation for `getMetaItemLayered` drops its dead `'overlay'` arm on `lockSource` and annotates with `MetadataLockSource` directly — the only producer feeding that field on the layered read path is `resolveLockState`, whose return is already typed `MetadataLockSource | undefined` (`'artifact' | 'package' | 'env-forced'`); the `'overlay'` literal in the file belongs to `getEffectiveLock`, a write/delete-door helper that never feeds this response. Type-level change only; no runtime behaviour or wire vocabulary changes.
+- bc03179: Fold the metadata lock gate's type key at its producer, so an ADR-0010 `_lock` can no longer be addressed around by spelling.
+
+ `getEffectiveLock` handed `type` to its two limbs verbatim, and the limbs did not read it the same way: the artifact limb folded (`lookupArtifactItem` resolves the singular and retries the raw spelling), while the overlay limb queried `sys_metadata` with the raw `type`. Since `SysMetadataRepository` stores rows under the canonical spelling with no at-rest fallback, a non-canonical `type` missed the stored active row and the gate fell through to `lock: 'none'` — not a neutral value but the verdict "the author declared no protection", which `evaluateLockForWrite` / `evaluateLockForDelete` turn into "allow".
+
+ `getEffectiveLock` now folds once with `canonicalMetaType` and uses that one key for both limbs. Nothing changes for any caller reachable today — `saveMetaItem`, `deleteMetaItem`, `rollbackMetaItem`, `publishMetaItem` and `publishPackageDrafts` all fold their own request first, which was measured rather than assumed. What changes is the failure mode of a future caller that does *not* fold: the lock is now found, and the write is refused instead of silently admitted.
+
+ The fold uses the URL map rather than the manifest-collection map on purpose — the latter omits `field`, `seed`, `external_catalog` and `translation`, i.e. it would canonicalize the types that never needed it and leave the four that do.
+- ead96d0: fix(metadata-protocol): the metadata read path no longer serves stored cleartext credentials (#8154)
+
+ `decorateMetadataItem` returned the whole stored body, so a `datasource` row
+ written before #8078 closed the write door came back with `config.password` in
+ cleartext — and the password embedded in `config.url` alongside it — from
+ `GET /api/v1/meta/datasources`, from the single-item read, and from the layered
+ read in **both** its `overlay` and `effective` layers. PR #8126 closed the
+ datasource-admin door (`GET /api/v1/datasources/:name`); this closes the
+ platform door one over. Meta read permission is granted at a far lower bar than
+ "may see the production database password", which is what made this reachable.
+
+ The fix consumes the per-type redactor registry #8300 landed in
+ `@objectstack/spec/kernel` (`getMetadataTypeRedactor`) rather than redacting
+ `datasource` specifically: `datasource` is that registry's first consumer, and a
+ type-shaped patch here would be the narrow fix that leaves the next
+ secret-bearing type exposed. A plugin whose metadata type stores secrets gets
+ the same protection by calling `registerMetadataTypeRedactor` — no change here.
+
+ Three properties worth knowing, each measured rather than assumed:
+
+ - **`_diagnostics` are still computed on the RAW stored body, before
+ redaction.** The redacted body is exactly the shape the post-#8078 schema
+ accepts, so computing them afterwards flips `valid:false` to `valid:true` on
+ precisely the rows that hold a stored credential — which would delete the
+ operator's only inventory of what still needs migrating (#8081 item 3). The
+ two steps are composed inside one function so no call site can invert an
+ ordering it cannot see.
+ - **The stored record is never mutated, and the connect path is untouched.**
+ Redaction is a serving act; datasource connection and boot-time restore read
+ `sys_metadata` directly through the data engine, not through these exits.
+ - **The write path carries the credential forward**, and this half is not
+ optional: `saveMetaItem` accepts a redacted body and persists the credential
+ away, so a read scrub shipped alone would convert today's loud `422` into
+ **silent credential deletion** on an ordinary GET → edit → PUT round trip.
+ `config.url` makes it unavoidable rather than a masking choice — a
+ URL-embedded password is schema-accepted, so dropping it round-trips to
+ deletion and masking it round-trips to storing the mask as the literal
+ password. Stored material is re-applied only where the incoming body is
+ indistinguishable from what the read served; anything the author actually
+ wrote wins and is still judged by #8078's write gate on its own merits. This
+ also restores the #4326 byte-identical round-trip invariant, which
+ read-redaction alone would have broken.
+
+ It preserves cleartext already at rest and creates none; moving stored
+ credentials into `sys_secret` is #8081 item 3's migration and is deliberately
+ not attempted on a write door an author drove.
+- c15eb23: fix(metadata): the `422 INVALID_METADATA` envelope descends `invalid_key` / `invalid_element`, so a rejected record key arrives with the rule it broke (#8783)
+
+ Zod raises a `z.record` / `z.map` **key** rejection as `invalid_key` and a
+ `z.map` **element** rejection as `invalid_element`, and in both cases the
+ issue's own `message` is a bare wrapper — `"Invalid key in record"` — with the
+ real diagnosis one level down in `issue.issues`. That is structurally the
+ `invalid_union` shape #4971 named: the prescription is produced and then
+ dropped by a walk that reads only the top level.
+
+ Both `packages/spec` walks learned to descend those codes in #5389.
+ `zodIssuesToMetadataIssues` — the walk behind `saveMetaItem`'s 422 (#5364) and
+ the read path's diagnostics (#5598) — expanded `invalid_union` only, so it
+ stopped at the wrapper. Three walks over one `safeParse`, two of them reaching
+ the prescription and the Studio-facing one not.
+
+ **It was reachable from ordinary authored metadata, not synthetic.**
+ `ObjectSchema.fields` is a record whose KEY schema carries the snake_case rule
+ (`spec/src/data/object.zod.ts`), and `object` is in the builtin
+ `getMetadataTypeSchema` registry. So the commonest authoring mistake on the
+ most-authored metadata type — writing `firstName` for a field key, which is
+ exactly what an agent coming from JS naming writes — produced:
+
+ ```
+ { path: 'fields.firstName', code: 'invalid_key', message: 'Invalid key in record' }
+ ```
+
+ The author was told a key was invalid and never told what a valid one looks
+ like, so the next move was to guess. The declared message existed and was
+ correct; it just did not reach anyone. Now the same save answers:
+
+ ```
+ { path: 'fields.firstName', code: 'invalid_key', message: 'Invalid key in record' }
+ { path: 'fields.firstName', code: 'invalid_format', message: 'Field names must be lowercase snake_case (e.g., "first_name", …)' }
+ ```
+
+ **Additive, and matched to the walks that already worked** rather than chosen.
+ The other two were measured over the card's own repro first: `formatZodIssue`
+ prints the wrapper line then the indented detail, and `zodIssuesToFields` emits
+ the `invalid_shape` wrapper entry then the detail entry. So the wrapper stays at
+ index 0 — it is the only entry naming the slot the client sent, and Studio's
+ designer keys on it — and the detail joins it on the same path. No entry that
+ shipped before is removed or renumbered.
+
+ **Targeted, not a widened walk.** Only the two container codes open the descent;
+ an `issues` array hanging off any other code is still ignored, `invalid_union`
+ still expands through the unchanged ranking, and the nesting bound now covers
+ both descents at the same depth of 3. Container issues are deliberately *not*
+ ranked the way union branches are: a union's branches are competing candidates,
+ while a container has one inner schema, so every issue it raised is a true
+ statement about the value.
+
+ The verdict is unchanged in every case — this moves what a refusal *says*, never
+ whether it is one. `union-branch-policy.cross-package-parity.test.ts` gains a §5
+ comparing the container descent across all three walks; its §1 (the policy is
+ not publicly exported from `@objectstack/spec`, so this package must run its own
+ copy) is untouched, and no export was added.
+- b740440: fix(metadata-protocol): the stored migration reports a non-canonical stored `type` as `skipped` instead of counting it `canonical` (#8957)
+
+ `migrateStoredMetadata` — the method behind `POST /meta/_migrate-stored` and
+ `os migrate meta --stored` — opened every row with
+ `PLURAL_TO_SINGULAR[rawType] ?? rawType`, the **manifest-collection** map. That
+ map legitimately omits the metadata types that are not stack collections, so
+ for a row stored under one of their plural spellings the fold was a no-op: the
+ pass looked up ADR-0087 body conversions registered for a type named `fields`,
+ found none, saw nothing had changed, and recorded the row `canonical`.
+
+ `canonical` is counted and never itemised — by design, because on a healthy
+ deployment that is every row — so the row disappeared from `report.rows`
+ altogether. The verdict means "nothing to do", and there was something to do:
+ the row sits in a second namespace that no registry read and no compliance
+ query on the canonical type can reach.
+
+ Since #8908, `publishPackageDrafts` **refuses** exactly these rows at its
+ pre-flight (`STORED_TYPE_NOT_CANONICAL`). The stored migration is the door an
+ operator naturally reaches for next, and it answered that the row was already
+ fine. The two doors now agree.
+
+ ## What changed in the report
+
+ The scan folds with the URL/registry map (`canonicalMetaType`) instead of the
+ manifest map, and a row whose **stored** spelling is non-canonical is reported:
+
+ ```jsonc
+ // before — the row was invisible
+ { "scanned": 1, "canonical": 1, "skipped": 0, "rows": [] }
+
+ // after
+ {
+ "scanned": 1, "canonical": 0, "skipped": 1,
+ "rows": [{
+ "type": "field", "name": "showcase_task.title", "outcome": "skipped",
+ "reason": "the row is stored under the non-canonical metadata type 'fields' ('fields/showcase_task.title'), and its canonical type is 'field'. …"
+ }]
+ }
+ ```
+
+ The reason names the stored spelling in the same `type/name` form the publish
+ refusal quotes, the canonical type, the other door's error code, and the
+ re-author path. `--type field` and `--type fields` now both reach the row —
+ the filter folds the same way, so the spelling an operator was just handed by
+ the publish refusal is not the one spelling that fails to find it.
+
+ The fold swap cannot change the answer for any spelling the old fold resolved:
+ `META_URL_TO_SINGULAR` embeds every manifest spelling verbatim under a
+ module-load agreement assertion, and measured on this tree the set of spellings
+ where the two folds disagree is empty. The set the new fold newly resolves is
+ exactly the six-member class `isNonCanonicalStoredType` derives (`fields`,
+ `seeds`, `external_catalogs`, `externalCatalogs`, `translations`,
+ `email_templates`), which is the set now reported.
+
+ ## What did NOT change
+
+ The method's contract. It still canonicalizes **bodies**, and it still writes
+ nothing for this class: rewriting a stored `type` is an identity move — a new
+ `(org, type, name, package_id)` key, history and audit continuity to decide,
+ and a collision question when the canonical row already exists — which #8908's
+ ruling parked as a follow-up needing its own appetite.
+
+ `storedMigrationClean` is also unchanged: `skipped` rows still do not flip it.
+ This pass has no lever for the condition, so failing the verdict over it would
+ give `os migrate meta --stored` a non-zero exit that no run of that command
+ could ever clear. The row is reported per-row instead, and the publish door is
+ what refuses it.
+- b6c7690: fix(rest): org-overridable metadata is served back by every `/meta` read door, not just persisted (#9454)
+
+
+
+ A runtime `PUT` of an org-overridable metadata type — `view`, `dashboard`,
+ `report`, `translation`, `email_template` — answered **200** with a receipt
+ reporting `state: 'active'` plus a version and sequence number, **persisted the
+ row with its `organization_id`**, and was then served back by **nothing**: the
+ direct `GET` answered 404, the scoped listing was unchanged, the unfiltered
+ listing was missing it, and the browser rendered an empty view or "Dashboard Not
+ Found". The platform reported success in the same breath as not delivering the
+ work, which is declared ≠ enforced in the direction hardest for an author to
+ notice — the write path says everything worked.
+
+ **The write door was correct as-is.** The row really is persisted, so the
+ receipt is truthful; this was persisted-but-not-served, never a silent write
+ no-op. **The overlay-resolution layer was correct too**, and type-agnostic:
+ `getMetaItem` resolves `(orgId ? findOverlay(orgId) : undefined) ??
+ findOverlay(null)`, `getMetaItems` unions both scopes under org-wins precedence,
+ and `getMetaItemLayered` even reports `overlayScope`. The defect was that the
+ REST read doors **never stated the scope**, so every one of them asked for the
+ env-wide partition and the org partition was never consulted.
+
+ **The repair is one registry-derived predicate, threaded at the read doors.**
+ `organizationIdForMetaRead` joins `organizationIdForMetaWrite` in
+ `metadata-core`, deriving from the same `allowOrgOverride` registry flag, so
+ read scope and write scope cannot drift and a registry entry flipping the flag
+ moves both doors together. It is threaded through the **already-memoised**
+ `resolveExecCtx`, so no new per-request organization resolution is introduced.
+
+ ⛔ **Not a bare `ctx?.tenantId` at each site**, and the reason is measurable
+ rather than stylistic: deployments predating the #6190 ruling hold **phantom
+ org-scoped rows for types the registry declares non-overridable** (the runtime
+ used to stamp `organization_id` on every type). Boot hydration deliberately
+ walks past those rows, so they are dead. A read door naming the org for *every*
+ type would resolve them again — serving, on the read side, a document that
+ vanishes at the next restart.
+
+ **`getMetaItemCached` gains an `organizationId` member** — it was the only meta
+ read verb that could not express one, having hard-coded a two-key delegation to
+ `getMetaItem`. The organization is also folded into its **ETag**. The mechanism
+ differs from `locale` and the difference is stated rather than glossed: `locale`
+ is invisible to the hash (the body is translated after the validator runs), so
+ folding it in was the only way it could vary the validator at all, whereas the
+ org-resolved document *is* the thing hashed. No cache leak is claimed — the
+ directive is `private, no-cache` and there is no server-side cache entry keyed by
+ type+name. It is folded in because that makes scope a **declared** property of
+ the validator instead of an emergent property of the body.
+
+ **Both REST branches are fixed, which is the half-fix this card could easily
+ have shipped instead.** `view` and `dashboard` share one mechanism but reach it
+ through two different arms: `view` takes the cached arm (`getMetaItemCached`),
+ while `dashboard` bypasses the cache via `isDashboardType` and takes the
+ uncached arm. Both omitted the org, so a fix applied to one arm would have
+ fixed exactly one type while the receipt kept claiming success for the other.
+ The scope is now resolved **above** the fork, so the two arms cannot disagree.
+
+ The regression proof drives real REST routes against a real protocol over a stub
+ engine — write-then-read agreement on **one boot**, for all five types, through
+ both arms. Its most important assertions are the ones that do **not** merely
+ check the item comes back: an org-less caller and a **second organization** must
+ each be refused it. An org-blind overlay fallback would satisfy every other
+ assertion in the file while matching an arbitrary tenant's row.
+- 845e164: fix(metadata-protocol): a package publish refused by the namespace-prefix rule now leaves an audit row per violation (#8595)
+
+ `publishPackageDrafts` refuses a whole batch pre-flight when an object draft's
+ name is missing its package namespace prefix (ADR-0028). That refusal returns
+ ABOVE the batch's `engine.transaction()`, so it reached neither the post-commit
+ `allowed` rows nor the rollback handler's `batch_aborted` row: it wrote nothing
+ to `sys_metadata_audit` at all. The compliance consequence is the defect — a
+ package rejected for a bad object name was **indistinguishable in the trail from
+ a package nobody ever pressed Publish on**, so a compliance query could not tell
+ a refused publish from one that never happened.
+
+ Each violation now leaves its own `publish` / `denied` row keyed on the
+ offending draft's `(type, name)` — the tuple `auditMetaItem` reads, so the
+ refusal is visible on that item's own audit-log tab via
+ `GET /api/v1/meta/:type/:name/audit`. The row carries the violated rule
+ (`namespace_prefix`) as its `code`, and the rule's actionable message as `note`.
+ Rows are keyed on the draft's own organization scope, matching the promoted
+ rows: an env-wide draft audits env-wide even when the publishing session carries
+ an active org.
+
+ One row per violation rather than one per batch: a pre-flight refusal names N
+ violating items and no single causal one, so a batch-level row would have had to
+ mint a synthetic identity — exactly what the `batch_aborted` row declines to do
+ for its own unattributable case.
+- 8d017eb: fix(metadata-protocol): route `publishMetaItem` through the `/meta` canonical-type fold (#8769)
+
+ `canonicalizeMetaRequestType` is the `/meta` request boundary, and its own
+ header describes it as the fold "all six entry points funnel through".
+ `publishMetaItem` is a **seventh** entry point on the same URL family
+ (`/api/v1/meta/:type/:name/publish` and the `…/published` overlay) and did not
+ funnel through it: it reached the draftability check through
+ `PLURAL_TO_SINGULAR`, the MANIFEST-COLLECTION map, which is the exact lookup
+ #7894 replaced at the other six. One contract, two dialects, decided by which
+ verb you used (Prime Directive #12).
+
+ The fix is the same one line the other six carry, at the top of the method. What
+ that line reaches — measured on `origin/main`, not inferred — differs by whether
+ the type is in the manifest map, and the two halves are not the same severity:
+
+ **The four manifest-absent types — fail-closed, but closed for the wrong reason
+ and with the wrong verdict.** `field`, `seed`, `external_catalog` and
+ `translation` are legitimately absent from `PLURAL_TO_SINGULAR` (they are not
+ stack collections; that absence is precisely why #7894 moved the boundary onto
+ the URL map). Unfolded, they arrived at the draftability check as unrecognised,
+ where `isRuntimeCreateAllowed`'s "no static registry entry ⇒ this is a
+ plugin-registered kind" arm answers **true** — the permissive plugin branch,
+ taken for a type the platform itself declares. So a publish addressed
+ `/meta/fields/showcase_task.title` PASSED a gate that `/meta/field/...` answers
+ `403 NOT_OVERRIDABLE`, and only failed further down, on `404 no_draft`, having
+ already forgotten which type it was judging. A publish addressed
+ `/meta/translations/zh_cn` likewise never resolved the draft that
+ `PUT /meta/translations/zh_cn` had folded and written under `translation`. After
+ the fold: the first is refused `403 NOT_OVERRIDABLE` by its real registry entry,
+ the second promotes the row it names.
+
+ **Manifest-present types — one lookup that did NOT fail closed.**
+ `promoteDraftForPublish` folds through the manifest map before the row lookup,
+ so a publish addressed `/meta/views/case_grid` always resolved the canonical
+ row. `getEffectiveLock` does not agree with it: its artifact limb folds, its
+ **overlay limb queries `sys_metadata` with the raw `type`**. Addressed with the
+ plural, the ADR-0010 `_lock` carried by the stored active row was looked up
+ under a `type` no row has and came back `'none'` — which is not a neutral value,
+ it is the verdict "the author declared no protection" (#5706) — while the
+ promote one line later read the folded key and overwrote the row the lock
+ protected. Measured on `origin/main`: `_lock: 'no-overlay'` plus a pending
+ draft, canonical spelling `403 ITEM_LOCKED`, plural spelling **200 and the
+ active body replaced**.
+
+ That window is narrow and is stated at its real width rather than rounded up: it
+ needs an environment kernel (the gate is skipped wholesale when `environmentId`
+ is `undefined`), a lock carried by a *stored overlay* row rather than a packaged
+ artifact, and a draft that predates the lock — because the save door refuses to
+ mint one once the lock is live. It is nevertheless a lock gate that could be
+ addressed around from the wire, and "a lock gate must not fail open" is the rule
+ this file already carries.
+
+ `promoteDraftForPublish`'s own `PLURAL_TO_SINGULAR` fold is **kept**, and the
+ measurement is the reason: that helper's other caller is `publishPackageDrafts`,
+ which feeds it stored row types. That is data at rest, where a legacy row
+ written under a plural `type` is real and nothing rewrites it on upgrade — a
+ different input class needing a different map, exactly as `canonicalMetaType`'s
+ header describes. Deleting it as "now redundant" would have changed the batch
+ path.
+
+ `publishPackageDrafts` and `deletePackage` need no fold of their own: neither
+ takes a caller-supplied `type` at all (both are addressed by `packageId`), and
+ the per-row work they delegate is already covered — `deletePackage` routes every
+ row through `deleteMetaItem`, which folds, and `publishPackageDrafts` reaches
+ the manifest-map fold described above.
+
+ The audit row and the publish receipt now record the canonical type too; both
+ read `request.type`, so a publish addressed `/meta/views/case_grid` previously
+ wrote `type='views'` into `sys_metadata_audit` for a row stored under `view`,
+ and a compliance query on the canonical spelling did not find it.
+
+ Pinned in `packages/objectql/src/protocol-publish-canonical-fold.test.ts`
+ against a real engine and repository, with the reverse verification's direction
+ predicted before it was run: predicted 3 red / 4 green, measured 3 red / 4
+ green, each red for its predicted reason.
+- 1a7f907: fix(metadata): a package publish refuses a draft stored under a non-canonical metadata type, and the ADR-0010 audit writer asserts its `type` instead of folding it (#8908)
+
+
+
+ **Two tightenings, one card, because they are the same defect at two layers.**
+
+ `publishPackageDrafts` reads `sys_metadata` rows **at rest**, so #7894's `/meta`
+ boundary fold never reached it. `promoteDraftForPublish` folds the stored
+ spelling through `PLURAL_TO_SINGULAR` — the *manifest-collection* map, which
+ legitimately omits types that are not stack collections. For those the fold is a
+ **no-op**: the lookup key equals the stored spelling, the draft resolves, and the
+ publish mints an ACTIVE row in the namespace `PUT /meta/field/…` answers
+ 403 NOT_OVERRIDABLE for. Measured on the card with the real repository over a
+ stub engine:
+
+ ```
+ publishPackageDrafts({ packageId: 'app.demo' })
+ → { success: true, publishedCount: 1, published: [{ type: 'fields', name: 'legacy_field' }] }
+ active row: { type: 'fields', name: 'legacy_field', package_id: 'app.demo' }
+ audit row: { type: 'fields', name: 'legacy_field', outcome: 'allowed', code: 'ok' }
+ ```
+
+ Every registry read and every compliance query on `field` misses an item the
+ platform just reported as published — the #4432 shadowing shape, minted at
+ publish time instead of at the URL, and the last route by which a pre-#7894 row
+ could be re-promoted rather than migrated.
+
+ **1. The publish refuses it, at the pre-flight, batch-atomically.** Same shape as
+ the ADR-0028 namespace-prefix gate that already stands there: found before
+ anything is promoted, failing the whole batch (`publishedCount: 0`,
+ `published: []`) rather than publishing the healthy siblings around it, with one
+ audit row per violation. The refusal names the row, names the canonical type, and
+ states the re-author path; `failed[].code` is the new
+ `STORED_TYPE_NOT_CANONICAL`, and the audit column's spelling is
+ `stored_type_not_canonical`.
+
+ The rule is **derived, not a list**: a spelling the platform's URL/registry map
+ folds elsewhere *and* the manifest map leaves unchanged. Against the real maps
+ that is **six** spellings — `fields`, `seeds`, `external_catalogs`,
+ `externalCatalogs`, `translations`, `email_templates` — where the card named
+ four; the last two would have been missing from any hand-written list, and a
+ newly declared type that never reaches the manifest map is covered on the day it
+ is declared. A manifest-**present** plural (`objects`) is deliberately *not* in
+ the class: it is already fail-closed at the promote (`NO_DRAFT`, batch aborted)
+ and keeps that verdict.
+
+ ⛔ Deliberately **not** included: migrating the row (a `_migrate-stored` /
+ boot-reconciliation conversion). That was the other option on the card and is
+ explicitly unruled — it stays available as a follow-up with its own appetite.
+
+ **2. `recordMetadataAudit` refuses a non-canonical `type` (`AUDIT_TYPE_NOT_CANONICAL`)
+ instead of folding it.** The writer used to open with
+ `type: PLURAL_TO_SINGULAR[entry.type] ?? entry.type` — a lenient consumer, and a
+ **tolerant-and-incomplete** one: the fold read the same manifest map, so the
+ compliance trail came out canonical for the 29 types that never needed it and
+ non-canonical for exactly the ones that did. Ruled the same direction as the
+ refusal above: **fold at the boundary, assert at the writer.** Every call site
+ that builds a row out of an at-rest `type` — all of them on
+ `publishPackageDrafts` — now folds with `canonicalMetaType`; the `/meta` routes
+ were already canonical by the time they got there. The throw sits **outside** the
+ writer's best-effort `try`, because inside it the method's own `catch` would
+ degrade the assert into a `console.warn`.
+
+ The assert cannot refuse a canonical type (no canonical spelling folds
+ elsewhere — 33 of 33, measured) nor a plugin-registered or otherwise
+ unrecognised kind (`canonicalMetaType` is the identity for anything the static
+ map does not carry), so it narrows the accept set without closing it.
+
+ **Reachability was enumerated before the assert landed**, as the ruling required:
+ `recordMetadataAudit` is private to `protocol.ts` with 11 call sites, `sys_metadata`
+ rows have exactly one producer in the repository (`saveMetaItem` → `repo.put`,
+ post-fold), and no current write path can mint a non-canonical stored type. The
+ only non-canonical types that ever reached an audit write came from the batch
+ publish's at-rest rows, which is what the boundary folds now cover.
+
+ Also fixed, as a consequence of that fold rather than as a separate change: on
+ the batch route `getEffectiveLock`'s overlay limb was queried with the raw stored
+ spelling, so an ADR-0010 `_lock` carried by the canonical active row was looked
+ up under a `type` no row has and came back `'none'` — the verdict "the author
+ declared no protection". That is the batch twin of the hole #8769 closed on
+ `publishMetaItem`.
+- 4e3a4c3: fix(metadata-protocol): four read seams that FAILED no longer answer out of an empty accumulator — only an unprovisioned table is read as truthful emptiness (#8896)
+
+ Four reads in `@objectstack/metadata-protocol` sat behind a bare `catch` that
+ fell through — or, in one case, jumped — above a value the read was supposed to
+ fill. Each handed its caller an answer indistinguishable from a legitimate one,
+ with nothing logged and no field saying the answer was incomplete. Per ADR-0110
+ D3 those are different facts, and at every one of these seams they have opposite
+ consequences:
+
+ - **`SeedLoaderService.loadExistingRecords()`** returned an empty `Map`. That map
+ is not a cache — it IS the write decision, in all three of its callers, and
+ "empty" means *write these rows*: the upsert pre-load turns every update into
+ an INSERT, and `bulkWrite`'s `attempt > 1` recheck — the only thing standing
+ between an at-least-once retry and a duplicate of every row the first attempt
+ already committed (framework#3149) — is silently disarmed.
+ - **`searchAll()`** skipped the object on a per-object `catch { continue; }`
+ while the response still reported `totalObjects` / `totalHits` / `truncated`
+ as though the sweep had been complete: a partial scan wearing a whole one's
+ numbers.
+ - **`findReferencesToMeta()`** dropped a whole source type on a per-matcher
+ `catch { return; }`. That list answers "what would break if I delete this" and
+ is rendered as the admin UI's "Used by" panel, so a silently short list reads
+ as "nothing depends on it — safe to remove".
+ - **`publishPackageDrafts()`** did not fall through: it pushed a **fabricated**
+ ADR-0067 revert-plan entry, `{ existedBefore: false, prevVersion: null }` —
+ the literal opposite of the healthy branch's `existedBefore: !!activeRow`.
+ `existedBefore: false` means "revert = soft-remove", so reverting that commit
+ DELETES an artifact whose previous version was supposed to be restored.
+
+ None of the four `catch`es is removed; each is **discriminated by error type**,
+ through the same shared `isMissingTableError` predicate
+ (`@objectstack/metadata/errors`) that `DatabaseLoader`, `SysMetadataRepository`
+ and `cascadeDeleteRelations` already use:
+
+ - **benign, unchanged** — the table was never provisioned (schema sync not run
+ yet). It can hold no rows, so the empty answer is the truth and each seam
+ behaves exactly as before: the seed writes its rows, the search skips the
+ object, the publish records `existedBefore: false`.
+ - **everything else now surfaces** — a connection drop, a timeout, a permission
+ denial, a query error, a missing column on a provisioned table. The caller
+ receives the read's own failure, envelope intact.
+
+ `findReferencesToMeta` is the one seam that gets no predicate of its own: it
+ reads through `getMetaItems`, which already performs exactly this discrimination
+ (`rethrowUnlessMetadataStoreUnprovisioned`, #5532) and raises a 503
+ `SERVICE_UNAVAILABLE` for a real outage. The only thing its `catch` could
+ swallow was that deliberate 503, so it is simply gone.
+
+ No new error code and no new response field. The behavioural change is that a
+ seed load, a global search, a reference scan or a package publish which used to
+ report success over an unreadable store now reports the failure that made it
+ unreadable. `publishPackageDrafts` refuses before Phase 1's transaction, so a
+ refused publish leaves the draft pending and writes nothing.
+
+ The comment above the publish capture claimed a capture failure "just omits that
+ item from the revert plan". That was wrong twice — the code fabricated rather
+ than omitted, and omitting would have left the item unreverted while reporting
+ the turn undone — and it now describes what the code does.
+- 3b0b61c: fix(metadata): the three read-side `/meta` verbs reach the canonical type boundary — history, audit and references (#9157)
+
+
+
+ Step ① of the maintainer ruling in #9180 (2026-08-16): **the `/meta` type
+ segment is singular, always.**
+
+ `auditMetaItem`, `historyMetaItem` and `findReferencesToMeta` each opened by
+ deriving their type key from `PLURAL_TO_SINGULAR` — the MANIFEST-COLLECTION map
+ that #7894 moved this boundary off — instead of calling
+ `canonicalizeMetaRequestType`, which the nine sibling `/meta` verbs already
+ call. That one call carries **both** the URL spelling map **and**
+ `metaUrlSpellingRefusal`, and the refusal is the half these three could never
+ reach: it lives *inside* the function they skipped.
+
+ **What changes on the wire**, on `GET /api/v1/meta/:type/:name/history`,
+ `…/audit` and `…/references`:
+
+ | caller's `:type` | before | after |
+ | --- | --- | --- |
+ | `viewes` — an unrecognised spelling of a **declared** type | 200 with an empty body | **400 `INVALID_REQUEST`**, naming both accepted spellings (`view`, `views`) |
+ | `translations`, `fields`, `seeds`, `external_catalogs` — recognised plurals of the four types absent from the manifest map | 200 with an empty body | 200 with the **real** rows |
+ | `views` — a recognised plural already in the manifest map | unchanged | unchanged |
+ | `fieldz` — reaches for no declared type | unchanged | unchanged; the refusal stays narrow, so a plugin-registered kind can never trip it |
+
+ The harm being closed is the empty-accumulator shape: a plural read answered
+ `{ "events": [] }` / `{ "references": [] }` — read by an operator as *"nothing
+ depends on this"* — at exactly the moment they were about to rename or delete.
+ **Loudly wrong beats quietly lying**, so a spelling the platform cannot honour
+ is now refused with the canonical one named rather than answered emptily.
+
+ Two measured details worth stating, because both invert an intuition:
+
+ - On `historyMetaItem` the unfolded plural was not merely a wrong key, it was a
+ door **around** a gate. `field` declares neither `allowOrgOverride` nor
+ `allowRuntimeCreate`, so the canonical spelling is refused by the overlay gate
+ and never reaches the store — while `fields` took
+ `isRuntimeCreateAllowed`'s no-static-registry-entry arm (the plugin path,
+ permissive by construction) and issued a real `sys_metadata_history` read
+ keyed `'fields'`. Same empty body, opposite path.
+ - On `findReferencesToMeta` the refusal is the **whole** visible change. Every
+ `REFERENCE_PATHS` key is manifest-present and already folded, so a
+ manifest-absent target still answers `{ "references": [] }` — which that
+ method documents as a legitimate no-hit. Widening that registry is a coverage
+ question, not a spelling one.
+
+ Recognised plural spellings are **not** retired here — `metaUrlSpellingRefusal`
+ returns `null` for `views` and for `translations`, and a pin asserts it. That is
+ #9180 step ③, which the ruling requires to stay independently revertible.
+- 8914915: fix(metadata-protocol): a failed `sys_metadata_commit` write is reported instead of swallowed — the turn that cannot be reverted is now visible to an operator (#9066)
+
+ `recordPackageCommit` — the ADR-0067 commit writer `publishPackageDrafts` calls
+ with the revert plan it just captured — sat behind a bare `catch` that answered
+ `null` for every reason, with nothing logged. The comment's premise was true
+ (the publish already succeeded and cannot be unwound) but its conclusion —
+ "grouping is a best-effort overlay" — understated the row: `sys_metadata_commit`
+ is the ONLY record of a turn's revert plan (`existedBefore` / `prevVersion` per
+ artifact), the thing `revertCommit` and `rollbackToPackageCommit` act on. When
+ the insert failed, the artifacts went live, the response read `success: true`
+ with `commitId` merely absent, the turn could never be reverted, and no line
+ anywhere said so — so a commit store that was failing kept failing, losing every
+ later publish's plan the same silent way.
+
+ The failure is now discriminated by error TYPE, through the shared
+ `isMissingTableError` predicate the read seams in this file already ask:
+
+ - an **unprovisioned** `sys_metadata_commit` (a first boot, or an environment
+ kernel composed without the commit log) is a configuration fact, identical on
+ every publish and fixed in one place — reported at `info`, once per protocol
+ instance, naming the consequence and how to provision the store;
+ - **every other** failure (connection drop, timeout, permission denial, schema
+ drift on that table) is a durability degradation and is reported at `error`,
+ once per turn, naming the package, the operation, the item count, the driver's
+ own reason, that the publish itself succeeded and still reports success, and
+ the fix.
+
+ Publish semantics are unchanged: the `catch` still returns `null`, the publish
+ still succeeds, and no response field was added — whether the caller should be
+ told the turn is unrevertible is a separate, undecided question.
+
+ The gate that stops this from regressing is extended in the same change: the
+ insert now goes through a named `persistPackageCommitRow`, declared in
+ `DURABILITY_CRITICAL_CALLEES` in
+ `scripts/check-durability-degradation-log-level.mjs`, so a future edit that
+ quiets this `catch` fails CI instead of shipping.
+- 2416dd5: fix(metadata-protocol): `revertCommit` refuses a non-canonical stored type on its restore limb, with the wire-visible code its sibling doors already give (#9174)
+
+ `isNonCanonicalStoredType` (#8908) names a six-member class of AT-REST spellings
+ whose type the manifest-collection map omits — `fields`, `seeds`,
+ `external_catalogs`, `externalCatalogs`, `translations`, `email_templates`. Rows
+ of that class are pre-#7894 residue: `PUT /meta/fields/…` answered 200 and
+ persisted before the `/meta` boundary fold closed that door, and nothing
+ rewrites them on upgrade.
+
+ Two doors that consume an at-rest `type` already answer for the class **by
+ name**: `publishPackageDrafts` refuses with a `failed[].code` of
+ `STORED_TYPE_NOT_CANONICAL` (#8908), and `migrateStoredMetadata` reports the row
+ `skipped` with the same reason stated in full (#8957). `revertCommit` is the
+ third consumer, and it is the producer #9111 traced and left explicitly
+ unguarded.
+
+ **Measured at HEAD before choosing a shape**, end to end over the real
+ `SysMetadataRepository` on an unscoped kernel, per limb:
+
+ - **restore limb** (`existedBefore: true`) — answered
+ `{ success: true, revertedCount: 1, failed: [] }` with
+ `reverted[0].action === 'restored'`, called `registerItem` **zero** times, and
+ left one line of server-side stderr as the only trace:
+ `[Protocol] registry write-through failed for fields/showcase_task.title:
+ [registry_type_not_canonical] …`. The receipt claims the pre-commit body is
+ what the platform now serves; for this class it cannot be — #9111's mint door
+ refuses the entry and boot refuses it too, so the restored body reaches no
+ reader at all.
+ - **soft-remove limb** (`existedBefore: false`) — answered
+ `{ success: true, action: 'removed' }`, the row **gone** from `sys_metadata`,
+ no warning emitted and no registry key touched. Nothing about that outcome is
+ wrong.
+
+ **The shape is `saveMetaItem`'s refusal**, carried on this door's existing
+ per-item `failed[]` channel — the same one `VERSION_NOT_FOUND`, `ITEM_LOCKED`
+ and `NOT_OVERRIDABLE` already ride. No new receipt surface and no new error
+ code: `STORED_TYPE_NOT_CANONICAL` is already this package's and already in the
+ error-code ledger. The test that separates it from `migrateStoredMetadata`'s
+ decline is whether the door can do what it *promises* for this row: the migrate
+ pass declines because rewriting a stored type spelling is an identity move and
+ out of its reach entirely, so `skipped` must not poison `storedMigrationClean`
+ for a scan that runs forever; here the write is squarely in reach and still
+ delivers none of what `restored` promises, which is `saveMetaItem`'s case. So it
+ is refused, and `success` goes false — the commit the operator asked to undo was
+ not undone, and a one-shot operator action has no forever to poison.
+
+ **The soft-remove limb is deliberately outside the gate.** It performs its
+ promise exactly and completely, and the removal is the one action that makes
+ this residue smaller; refusing it would answer `success: false` for a revert
+ that fully succeeded and would hand back an instruction ("drop the `fields`
+ row") naming the very operation it had just declined to perform.
+
+ **Nothing is folded.** The refusal writes no audit row and no commit record, and
+ carries the stored spelling into `failed[]` verbatim, so #9161's ruling — the
+ caller's spelling reaches the ledger keys unfolded, and `AUDIT_TYPE_NOT_CANONICAL`
+ fires loudly when it is wrong — is untouched in both directions. A refused item
+ is simply absent from `reverted[]`, so the append-only revert commit built from
+ it never claims an undo that did not happen.
+
+ The predicate stays the narrow at-rest one rather than the complete
+ `canonicalMetaType(t) !== t`: `objects`/`views` fold in the manifest map, so the
+ restore limb already hands the write-through a canonical key and those rows are
+ not this defect — widening would change a wire-visible `failed[].code` for them.
+- 88ef34d: fix(metadata-protocol): `rollbackMetaItem` routes through the canonical type fold, closing an ADR-0010 `_lock` a plural URL spelling could address around (#8819)
+
+ `rollbackMetaItem` is the **eighth** `/meta` entry point on the
+ `POST /api/v1/meta/:type/:name/rollback` URL family, and it was the last one
+ still deriving its type key from `PLURAL_TO_SINGULAR` — the
+ MANIFEST-COLLECTION map #7894 moved this boundary off — instead of
+ `canonicalizeMetaRequestType`. The other seven fold; this one did not.
+
+ **The half of that asymmetry that was not fail-closed is the lock.**
+ `assertLockAllowsWrite` delegates to `getEffectiveLock`, whose artifact limb
+ folds and whose **overlay limb queries `sys_metadata` with the raw `type`**. The
+ rollback passed the caller's spelling to the gate while every row operation
+ below it used the folded key. So for a manifest-present type, a rollback
+ addressed `/meta/views/case_grid/rollback` looked the `_lock` up under a `type`
+ no row carries, got `'none'` back — which is not a neutral value but the verdict
+ "the author declared no protection" (#5706) — and then restored the history body
+ against the folded key, which resolves the protected row perfectly. A lock gate
+ addressable around from the wire, on the verb that overwrites the active body.
+
+ **The severity window is narrow and is not rounded up here.** It needs an
+ environment kernel (`assertLockAllowsWrite` opens with
+ `if (this.environmentId === undefined) return null`, skipping the gate wholesale
+ otherwise) **and** a lock carried by a **stored overlay row** rather than a
+ packaged artifact — the artifact limb folds, so an artifact `_lock` was already
+ found under either spelling. Inside that window the write landed.
+
+ The fold also reaches three things that were merely incoherent rather than
+ unsafe: the revertability tier (`isOverlayAllowed` / `isRuntimeCreateAllowed`)
+ took the permissive **plugin** branch for the four manifest-absent types
+ (`field`, `seed`, `external_catalog`, `translation`); and the
+ `[not_overridable]` refusal, both ADR-0010 audit rows and both receipt sentences
+ reported the **caller's** spelling for a row written under the canonical one.
+ `recordMetadataAudit` re-folds internally through `PLURAL_TO_SINGULAR`, which
+ covers a manifest-present plural and misses the four manifest-absent ones — so
+ folding at the boundary is what makes the audit trail agree with the write for
+ both classes.
+
+ Placed after the existing `toVersion` envelope guard rather than at the very top
+ of the method: that is the position `saveMetaItem` documents for this exact pair,
+ naming this method's opening guard its structural twin — a malformed request
+ envelope is refused before its type key is canonicalised, and both refusals are
+ `[invalid_request]`/400 either way.
+
+ **What this does not do.** `getEffectiveLock`'s overlay limb still queries the
+ raw `type`. Folding it there would close the class at the producer for every
+ present and future caller, which is the contract-first shape — but it is a
+ shared gate whose blast radius wants its own measurement, so it is deliberately
+ left open as its own card rather than ridden in here.
+
+ Pinned in `packages/objectql/src/protocol-publish-canonical-fold.test.ts` as
+ group D, driving the real `ObjectQL` / protocol / `SysMetadataRepository` over an
+ in-memory driver on an environment kernel: the canonical spelling is refused by
+ the lock, the plural spelling is refused by the **same** lock, and — the clause
+ that matters, since the first two can both pass while the write still lands —
+ the protected active body is **unchanged** afterwards. A positive control runs
+ the identical plural call with the lock removed and asserts it really does
+ restore the earlier body, so the group cannot pass by being unable to roll back
+ at all.
+- add2d19: fix(metadata-protocol): global search titles a hit from the canonical `nameField`, not only the deprecated `displayNameField` alias (#8786)
+
+ `searchAll` — the global-search (⌘K) palette — resolved a hit's title from a
+ candidate list that opened with `obj.displayNameField` **alone**. Under
+ ADR-0079 `nameField` is the canonical primary-title pointer and
+ `displayNameField` is the deprecated alias, so this was the one consumer a
+ canonical designation could not reach.
+
+ It is reachable rather than theoretical because `provisionPrimary` — the
+ ADR-0079 designation seat the SchemaRegistry runs on every object at
+ registration — stamps `nameField` **only** and never the alias. An object that
+ declares its primary title canonically, without also carrying the deprecated
+ alias, produced `undefined` for that entry, the entry was filtered out of the
+ candidate list, and the title fell through to `String(row.id)`: the palette
+ showed a raw record id where the object's own declared, populated title
+ existed.
+
+ Impact was bounded to objects whose primary title is **outside**
+ `name` / `full_name` / `title` / `subject` / `label` / `company` — anything in
+ that conventional list already resolved through the later entries, which is why
+ this stayed invisible. An object declaring `nameField: 'company_name'` now
+ titles its hits `Acme Industrial` instead of `acc_1`.
+
+ The fix reads the precedence the rest of the platform already spells —
+ `obj.nameField ?? obj.displayNameField` — matching `resolveDisplayField`
+ (`@objectstack/spec`), the #4254 ingress gate, and this same function's
+ search-field resolution 44 lines below. The deprecated alias is still honored
+ on its own; only objects that carry **both** pointers naming **different**
+ fields see a precedence change, and no such object exists in this repo (every
+ one that carries both spells them identically).
+
+ Presentation only: which rows come back is untouched.
+- 5d4d20e: fix(metadata-protocol): SeedLoader asks the registry for a `name` column before probing it — no more hundreds of provoked `INVALID_FILTER` refusals per seeded boot (#9071)
+
+ `SeedLoaderService.resolveFromDatabase()` resolves a reference authored as a
+ natural key by walking a probe chain: the target dataset's declared
+ `externalId`, then the historical `name` default, then the internal `id`. The
+ `name` leg was spelled **unconditionally** — including on objects that have no
+ `name` column at all.
+
+ On those objects the probe is not a cheap miss. The driver **refuses** it:
+
+ ```
+ [sql-driver] INVALID_FILTER — Filter on 'name' names a column that object
+ 'crm_contact' has no column for, so the predicate never ran.
+ ERROR Find operation failed {"object":"crm_contact", …}
+ ```
+
+ and it is right to. A predicate naming a column the object does not have never
+ ran, so answering "no rows" would be a lie (ADR-0110 D3 — a miss and a fault are
+ different facts). One refusal is raised per reference value, per pass: a real
+ `serve` boot with a 342-row seed emitted **hundreds of ERROR-level
+ `Find operation failed` lines**, on every seeded boot and every per-organization
+ replay, each reading exactly like a real failure that everyone downstream has to
+ learn to ignore.
+
+ **The fix is on the asking side, not the answering side.** The driver's refusal
+ is untouched — not caught more quietly, not downgraded, not filtered out of the
+ log. Instead the loader now asks the metadata registry whether the target
+ declares a `name` column, through the same `resolveObjectDefinition` resolver it
+ already builds the reference graph from (metadata service first, then the
+ engine's own schema registry), and drops the leg when the answer is no.
+
+ Which probe answers cannot change: on an object with no `name` column that leg
+ could only ever throw, never match. The guard covers the leg in **both** of its
+ positions — the fallback, and the first position it occupies when a referenced
+ target carries no dataset in this load and keeps the metadata-level `name`
+ default.
+
+ **An unknown is not a denial.** When neither the metadata service nor the
+ engine's schema registry can describe the object, the leg is kept — the
+ historical behaviour — rather than narrowed on a fact nobody established. The
+ answer is memoised per `load` (the question is asked once per unresolved
+ reference value, hundreds per boot) and re-asked on the next one, since a
+ publish between two loads can add the very column it is about.
+- 2b9d33a: Stamp seeded rows with the install's organization so one object runs one autonumber scope (#8686)
+
+ Seed writes and API writes disagreed about tenancy. Seed data is loaded during
+ app start, before any human user exists, so the seed loader had no organization
+ to stamp and its rows landed `organization_id = NULL`; API writes carried the
+ signed-in user's organization. The SQL driver keys its autonumber counter by
+ exactly that column (`__global__` when NULL), so a single object ran two
+ independent counters — and the uniqueness index is partitioned by the same key
+ (`COALESCE(organization_id, '__global__'), `), so the duplicates the
+ second counter minted were invisible to the constraint. On a single-tenant
+ install seeded with `CASE-00001..38`, the first four API creates returned
+ `CASE-00001..4` again: four duplicated values on a field declared `unique`, with
+ 201s and no warning.
+
+ Seed writes now carry the organization the same way API writes do. The moment an
+ install's organization first exists, untenanted seed rows are adopted into it and
+ the `__global__` counter is merged into the organization-scoped one, so the
+ `__global__` pseudo-tenant stops acting as a peer of a real organization. Existing
+ installs are repaired by a one-shot boot-time backfill, guarded to single-tenant
+ installs; a multi-tenant install where a split is detected is never guessed at —
+ the backfill skips and logs the condition and the remedy. Business identifiers
+ that were already minted twice are reported for the operator, never silently
+ renumbered. Platform namespaces (`sys_`/`cloud_`/`ai_`) stay global, exactly as
+ the seed loader already treats them.
+- 593c4bf: feat(spec): `storage` becomes the canonical `CoreServiceName` slot; `file-storage` stays a deprecated v17 alias (#9683)
+
+
+
+ Maintainer ruling, 2026-08-18, verbatim: 「9683 file-storage 可以叫 storage」.
+ The `file-storage` slot was the only `CoreServiceName` member whose spelling
+ diverged from its documented accessor (`services.storage`), with no recorded
+ reason anywhere in the tree.
+
+ - `CoreServiceName` gains `storage` as the canonical member; `file-storage`
+ stays an accepted, deprecated alias within v17 (it is a published enum
+ member — existing `getService('file-storage')` callers keep working).
+ `CORE_SERVICE_PROVIDER` and `ServiceRequirementDef` carry both.
+ - `@objectstack/service-storage` registers the **same instance** under both
+ names (the `http.server` / `http-server` pattern), pinned by an
+ alias-equivalence test.
+ - Every internal consumer resolves `storage`: the HTTP dispatcher, the email
+ plugin's attachment store, and `os migrate files-to-references`. Discovery
+ reports the service under the canonical `storage` key and mirrors the row
+ verbatim under the `file-storage` key for the alias's v17 lifetime, so
+ existing discovery readers (e.g. the console endpoint catalog) keep
+ working.
+ - Docs (`kernel/runtime-services`, `kernel/contracts`) now document the
+ canonical slot; a custom v17 provider for this slot should register both
+ names.
+- 0f59584: fix(metadata-protocol): `sys_setting`'s declared row identity is enforced on the tenant and global layers — a runtime NULL-safe UNIQUE index over `COALESCE(user_id, '')` (#8629)
+
+
+
+ `sys-setting.object.ts` declares the object's row identity as
+ `{ fields: ['namespace', 'key', 'scope', 'user_id'], unique: 'organization' }`,
+ and the object's own header calls that the row identity. It was not one.
+ `user_id` is NULL on every row that is not `scope='user'` — `SettingsService.set`
+ computes it as `scope === 'user' ? ctx.userId ?? null : null` — and SQL UNIQUE
+ treats NULLs as mutually distinct, so the constraint was **void on the `tenant`
+ and `global` limbs**: exactly the two carrying organization-level and
+ platform-level configuration.
+
+ Measured on a real engine, before this fix: two identical `scope='tenant'` rows
+ in ONE organization both landed (`201`, `201`), two identical `scope='global'`
+ platform defaults both landed, while the same rows with a non-NULL `user_id`
+ were refused — the control that identifies the mechanism as the NULL rather than
+ the `scope` value. `SettingsService` then resolves a layer with a positional
+ `rows.find(...)` and `set()` upserts against `{ namespace, key, scope, user_id }`,
+ so which value an organization got for a tenant-scoped key was unspecified and
+ two rows could disagree indefinitely with no way for an admin to see why the
+ effective value was not the one they set. `lifecycle.retention_overrides` is a
+ live tenant-scoped key, so this reached real retention behaviour.
+
+ The fix follows the paradigm that has shipped twice in this package
+ (`ensureOverlayIndex`, `ensureViewDefinitionActiveIndex`): at `kernel:ready` the
+ declared index is rebuilt in raw SQL with both nullable key parts folded —
+ `COALESCE(organization_id, '__global__')` (ADR-0120 D3's tenant form, unchanged
+ from what the driver already emits) and `COALESCE(user_id, '')` (the
+ `ensureOverlayIndex` spelling for a non-tenant nullable discriminator). Storage
+ is untouched: the row keeps its NULL, only the index folds it. The index reuses
+ the **declared name**, so the additive `syncDeclaredIndexes` — which skips by
+ name — never re-imposes the NULL-distinct form on a later boot, and the drift
+ reconciler leaves it alone because an index carrying a non-tenant expression key
+ part is not sync-reproducible.
+
+ **⚠️ Operator-visible: this is a TIGHTENING, and on an installation that has
+ already accumulated duplicate settings rows it will REFUSE to build the index.**
+ That is the intended behaviour, not a failure mode to work around. Those
+ duplicates exist precisely because the constraint has been void, and settings
+ rows are admin-authored configuration, so no row is discarded automatically and
+ no deterministic keep-one rule is applied. On refusal:
+
+ - **nothing is deleted, rewritten or reordered**, and the boot continues;
+ - the **previous index stays in place** — the tightening is proved buildable
+ under a throwaway probe name before the declared name is ever dropped, so the
+ table never spends a moment with no unique index at all;
+ - one `error` line names the key that is not enforced, the consequence (duplicate
+ tenant-scope and global-scope rows can still be created, and `SettingsService`
+ has no defined answer for which one wins), and ships the **exact query that
+ lists the offending rows**, so the operator has the list from the boot log
+ without waiting for `os migrate plan`;
+ - the migration keeps refusing on every boot until an operator decides which row
+ survives, then converges on the next restart.
+
+ Two hosts are deliberately quiet rather than degraded: a kernel composed without
+ the optional `service-settings` has no `sys_setting` table at all, which is
+ probed for and is a silent no-op; and a MySQL/MariaDB server that rejects
+ functional key parts keeps the previous index and is told what is not enforced,
+ the same degradation `SqlDriver.createNullSafeUniqueIndex` already reports for
+ this class of event.
+- f6c904a: fix(metadata-protocol): the sys_setting degradation report hands MySQL operators a duplicate-probe statement MySQL can actually run (#9434)
+
+ When `sys_setting`'s NULL-safe row-identity index cannot be built, the migration
+ degrades and prints a query so the operator can list the duplicate rows
+ themselves. The `unsupported` arm is reached specifically on MySQL/MariaDB — no
+ functional key parts, no `CREATE INDEX IF NOT EXISTS` — so that arm's audience is
+ exactly one dialect, and the statement it printed used bare identifiers. `key` is
+ a RESERVED word on MySQL, so the one remedy offered to a MySQL operator came back
+ as `ERROR 1064 (42000)`, measured on a live MySQL 8.0.46. Nothing in the platform
+ executes the statement, so no boot path was affected — what failed was the
+ operator's copy-paste, in the arm that has no other remedy to offer.
+
+ That arm now prints the MySQL spelling: every identifier quoted with backticks,
+ the convention `seed-tenancy-backfill.ts` adopted for the same reason in #9381.
+ Both spellings are generated from one body over one key-part array, so the
+ operator's list and the index's own key cannot drift apart, and the ANSI
+ statement `buildSysSettingDuplicateProbeSql()` returns is unchanged byte for byte
+ — the `conflict` arm still prints it, because a conflict means real rows blocked
+ a build the server was willing to attempt, which only SQLite and PostgreSQL ever
+ are.
+
+ Identifiers are quoted uniformly rather than only where a word looks reserved:
+ MySQL's reserved-word list grows across point releases, and #9381's `last_value`
+ is the recorded case of quoting the table while leaving a reserved column bare.
+
+ The `CREATE UNIQUE INDEX` statement is deliberately untouched and still bare.
+ MySQL refuses it for reasons quoting does not reach — unparenthesized `COALESCE`
+ key parts — and that verdict is now checked on a live server rather than
+ asserted, in both its quoted and unquoted spellings.
+- 159e299: Global search (`GET /api/v1/search`) now resolves searchable fields the same way `$search` does, so the ⌘K palette recalls what the list quick-search recalls (#7643)
+
+ `searchAll` built its own filter instead of going through the engine's ADR-0061 `$search` expansion, which made the palette's recall a strict subset of the executor's. It now hands the engine `search: ` per object and lets one expansion resolve the fields and compile the clause.
+
+ What a caller observes changing on `GET /api/v1/search` — both are widenings; no query that returned a hit before returns fewer:
+
+ - **Pinyin/initials recall now works on this endpoint.** Where the deployment provisions the hidden `__search` companion column (`OS_SEARCH_PINYIN_ENABLED`), latin terms are OR-ed against it, so `hnkj` and `huaningkeji` now return the CJK-named record that `POST /api/v1/data/:object/query {"search":"hnkj"}` already returned. Previously: 0 hits.
+ - **Which columns are scanned now follows the object, not a field flag.** Resolution is the object's declared `searchableFields`, else the auto-default (display/name field plus short-text and enum fields) — the set `searchableFields` documents itself as governing. The endpoint previously scanned only text-typed fields carrying the field-level `searchable: true` flag, falling back to the title field alone, so most objects were searched on one column. Hits from a second column (an email, a description, a select's label) are new.
+ - Enum (`select`/`status`) columns are now matched by option LABEL, and virtual `formula` fields are excluded, both as on the executor path.
+ - **The endpoint no longer substring-scans primary keys.** An object whose only text-typed column is `id` — system tables, junction tables, append-only logs — used to fall through to "the first text-typed field" and be queried as `{id: {$icontains: term}}` on every keystroke. Such objects are now skipped, as `$search` already skipped them (#4483). Callers relying on a bare `id` fragment matching through this endpoint will no longer get that hit; query the record by id instead.
+
+ Unchanged: which objects are swept and their opt-outs (`enable.searchable`, `enable.apiEnabled`, the `sys_*` skips), the per-object and overall caps, ordering, RLS/RBAC enforcement, and the response shape. The `$search` executor path itself is untouched. A record matched only through the pinyin companion has no `snippet` — no source column contains the typed term.
+
+ Also corrects the stale case declaration on this path (#7850): the doc comment said "case-insensitive LIKE" while the sentence below it named `$contains`, which #4706 Q2 = A defines as case-**sensitive**. Matching folds case via `$icontains`; behaviour is unchanged by that edit.
+- 52fbba6: `auditMetaItem` no longer reports a failed audit read as an empty audit trail
+
+ The `catch` closing the audit read in `ObjectStackProtocolImplementation.auditMetaItem`
+ was unqualified. Its comment named two benign causes — the `sys_metadata_audit` table not
+ being provisioned (legacy environments) and a host engine that exposes no `find` — but the
+ clause took every other cause with them: a connection drop, a permission denial, a
+ timeout, a malformed row, a query bug. Each was reported to the caller as the well-formed
+ statement `{ events: [] }`, i.e. "this item has no audit entries".
+
+ This is the compliance surface behind `GET /api/v1/meta/:type/:name/audit`, which exists
+ so Studio's audit-log tab can show who tried what and whether a lock blocked it, so an
+ empty answer reads as *nobody touched this item*. Because the swallowed failures are
+ transient, the same item could report a full trail one minute and a clean one the next.
+
+ Both benign causes still answer `{ events: [] }` exactly as documented. Every other read
+ failure now raises `SERVICE_UNAVAILABLE` / 503 carrying the driver error as `cause`, which
+ the route's existing error handler turns into an honest 5xx — the same treatment the
+ sibling `listCommits` and `getMetaItem` reads in this package already give (ADR-0110 D3: a
+ miss and a fault are different facts).
+- d5156b9: Remove the four dead `'objects'` spelling tolerances in the metadata protocol's object registry and storage seams.
+
+ `applyObjectRegistryMutation`, `applyRegistryWriteThrough`, `ensureObjectStorage` and `dropObjectStorage` each admitted a plural `'objects'` type key, and the first of them *registered under it* — the spelling-tolerant-lookup shape `canonicalMetaType`'s header rejects, and the one that previously let a plural registry entry shadow an entire code-authored listing.
+
+ All four are unreachable: every producer folds the type through `PLURAL_TO_SINGULAR` / `canonicalMetaType` before these seams see it. No behaviour changes for any caller that folds — which is all of them. What changes is the failure mode of a future caller that does *not* fold: it no longer silently registers an object under a plural key, so `assertObjectRegistered` fails closed with a loud, recoverable error instead.
+
+ Folding at the producer remains the rule; these guards were never a second line of defence.
+- 75e66fc: Stop `GET /api/v1/meta/:type/:name/diff` serving stored credential values.
+
+ `diffMetaItem` compared two stored metadata bodies and emitted the raw values it
+ found, so a `datasource` row whose credential rotated between versions returned
+ both the old and the new password in cleartext (inline `config.password` and the
+ password component of `config.url` alike).
+
+ The diff is still computed on the RAW bodies — a credential rotation continues to
+ report its path as changed — but the emitted `value` / `from` / `to` are now taken
+ from the type's redacted projection of those same bodies, on both sides. Types
+ with no registered redactor are unaffected and keep serving their values by
+ reference.
+- a726154: test(metadata-protocol): pin the THIRD union-branch policy copy against `@objectstack/spec` (#8660)
+
+ The union-branch selection policy — kind-mismatch drop, fewest-issues ranking,
+ `unrecognized_keys` tie-break, declaration-order determinism, depth limit 3,
+ branch cap 3 — has three implementations. #8318 (PR #8659) consolidated the two
+ inside `packages/spec` into one package-internal module and pinned them with a
+ shared-fixture parity test. The third, `zodIssuesToMetadataIssues` in
+ `protocol.ts` (the walk behind `saveMetaItem`'s `422 INVALID_METADATA` and the
+ read path's diagnostics), was structurally out of that consolidation's reach:
+ the shared module is deliberately not a public export (#4001), so a consumer in
+ another package cannot import it.
+
+ That left this copy exactly where the spec pair sat before #8318 — held in step
+ by a header comment and nothing else. A future tie-break or ranking tweak lands
+ in `union-branch-policy.ts` for both spec walks at once and silently not for
+ this one, and then the same authored metadata gets one prescription from the
+ terminal, another from the data API, and a third from Studio: the forked verdict
+ #5014 ruled out.
+
+ `src/union-branch-policy.cross-package-parity.test.ts` is the enforcement the
+ header stood in for. One fixture corpus, one `safeParse` per fixture, three
+ walks reached through PUBLIC surfaces only — `formatZodIssue` from
+ `@objectstack/spec`, `zodIssuesToFields` from `@objectstack/spec/api`, and this
+ package's own copy — compared as ordered `(path, message)` pairs. The corpus
+ covers every element of the policy by name, plus a hand-authored expectation per
+ fixture so both sides drifting the same way still fails. The two deliberate
+ asymmetries (the prose-only omission line, and raw zod codes here vs the
+ ADR-0114 catalog on the wire) are asserted in place rather than normalised away.
+
+ **`patch`, deliberately not a skipped changeset.** No production line changes,
+ no export moves, and every assertion is green on `main` before this lands — but
+ the bump floor is right rather than absent, for the same reason
+ `legacy-unique-guard-attribution` took one: what ships is a ratchet on
+ release-relevant behaviour. The 422 envelope this pins is a published contract
+ of `@objectstack/metadata-protocol`, and a consumer reading the CHANGELOG should
+ be able to see when its verdict acquired mechanical protection against drifting
+ away from the spec's.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [34392a1]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [13d7864]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2d0af57]
+- Updated dependencies [7337f30]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [818c27c]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [2a9752c]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [1408ae3]
+- Updated dependencies [e2899f6]
+- Updated dependencies [b6c7690]
+- Updated dependencies [3851f87]
+- Updated dependencies [845e164]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [7fc01db]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [b849e69]
+- Updated dependencies [71ac21c]
+- Updated dependencies [192213f]
+- Updated dependencies [42d8990]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/lint@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/metadata@17.1.0
+ - @objectstack/metadata-core@17.1.0
+ - @objectstack/formula@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json
index 4646365931..1e2f723860 100644
--- a/packages/metadata-protocol/package.json
+++ b/packages/metadata-protocol/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/metadata-protocol",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "ObjectStack metadata management protocol: sys_metadata CRUD, draft/publish, locks, package ownership, diagnostics (ADR-0076).",
"type": "module",
diff --git a/packages/metadata/CHANGELOG.md b/packages/metadata/CHANGELOG.md
index 1cd39fc688..e742f487ff 100644
--- a/packages/metadata/CHANGELOG.md
+++ b/packages/metadata/CHANGELOG.md
@@ -1,5 +1,174 @@
# @objectstack/metadata
+## 17.1.0
+
+### Patch Changes
+
+- 7337f30: 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.
+- 2a9752c: fix(metadata): `MetadataPlugin`'s `watch` option defaults to `false`, as its own doc comment documents (#9770)
+
+ `MetadataPluginOptions.watch` documents its default as ``Default: `false` (post PR-10e —
+ was previously `true`)``, directly above the field. The constructor implemented the
+ opposite, and it did so in **two** places, so both entry shapes resolved `true`:
+
+ - the options literal `{ watch: true, ...options }` — covering a caller who **omits** the key;
+ - the fallback `this.options.watch ?? true` — covering a caller who passes an explicit
+ `undefined`.
+
+ Both non-test construction sites in this repo pass `watch: false` explicitly and are
+ unaffected either way, which is exactly why the drift was invisible to every test and
+ gate: no in-repo configuration exercised the default. `MetadataPlugin` is a public export
+ (`@objectstack/metadata`, `@objectstack/metadata/node`), so the consumers who did reach it
+ were **external** ones — and they reached it by doing the documented-safe thing and not
+ naming the key at all. What they got was the configuration both internal call sites go out
+ of their way to refuse, citing an **EMFILE** hazard at both: a recursive chokidar poll
+ (`usePolling: true, interval: 1000`) over the entire project root, with `node_modules`
+ excluded only by chokidar's default `ignored`.
+
+ The default now resolves `false`. The flag is normalized once in the constructor
+ (`watch: options.watch ?? false`) rather than spelled `{ watch: false, ...options }`,
+ because a spread preserves an explicitly-passed `undefined` verbatim and not every read of
+ the flag routes through a nullish fallback — the `start()`-time `FileSystemRepository`
+ `disableWatch` keys on `=== false`. Coercing once makes an omitted key and an explicit
+ `undefined` resolve identically at every downstream read, instead of trading one
+ two-spelling divergence for another.
+
+ This is a default flip, **not** a capability removal: an explicit `watch: true` still
+ attaches the scanner and its watcher, and the sealed-runtime carve-out
+ (`bootstrap: 'artifact-only'` forces watching off even against an explicit `watch: true`)
+ is untouched. Pins cover all four shapes, asserting on the **observable** — whether a
+ watcher object exists on the manager — rather than on the resolved options value alone.
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2d0af57]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [bd2fc8b]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [b6c7690]
+- Updated dependencies [3851f87]
+- Updated dependencies [845e164]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [7fc01db]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/metadata-fs@17.1.0
+ - @objectstack/metadata-core@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/metadata/package.json b/packages/metadata/package.json
index ac383dbe26..92f53f7c11 100644
--- a/packages/metadata/package.json
+++ b/packages/metadata/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/metadata",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Metadata loading, saving, and persistence for ObjectStack",
"type": "module",
diff --git a/packages/objectql/CHANGELOG.md b/packages/objectql/CHANGELOG.md
index 370e78c41c..1b961e23c1 100644
--- a/packages/objectql/CHANGELOG.md
+++ b/packages/objectql/CHANGELOG.md
@@ -1,5 +1,1255 @@
# @objectstack/objectql
+## 17.1.0
+
+### Minor Changes
+
+- e374b4d: 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.
+- a8189ae: feat(objectql,metadata-protocol): refuse a dotted filter key whose head is a relation, a formula, or a plain scalar — at both doors (#8371)
+
+
+
+ **BREAKING** accept-set narrowing on the FILTER axis, landing after the v17.0.0
+ cut (the lockstep launch-window convention ships it as `minor`; the migration
+ prescription is registered under protocol major 18, where `objectstack migrate
+ meta` users will look).
+
+ FILTER was the last of the four query axes with no verdict for a dotted name:
+ SORT refuses it (#4256), PROJECTION refuses it at both doors (#7589), while
+ `where: { 'project_id.name': 'Apollo' }` cleared the unknown-field check on its
+ head segment and answered `200` with zero rows. Measured across all three
+ drivers before ruling (#8371): relation-head, formula-head, system-column-head
+ and plain-scalar-head dotted filters return zero rows on `driver-memory`,
+ `driver-sql` and `driver-mongodb` alike — a lookup stores the related record's
+ scalar id, so there is no working capability for this refusal to remove; every
+ answer was a silent empty list indistinguishable from an empty table, and the
+ virtual case answered one unserviceable intent two ways by spelling
+ (`{is_open: true}` refused since #8296, `{'is_open.x': true}` not).
+
+ **What is refused:** a dotted filter key whose head field is a relation
+ (`lookup`/`master_detail`/`user`/`tree`), a virtual `formula`, or a plain
+ scalar — `400 INVALID_FIELD`, naming the whole offending key, at both the REST
+ ingress (`assertFilterFieldsExist`) and the engine's own filter seam
+ (`assertFilterIsMaterializable`, reached by saved reports, flows and dashboard
+ widgets whose filters never pass the ingress). Both doors judge the head by the
+ shared `@objectstack/spec/data` classification (`classifyDottedFilterHead`,
+ new export), so they cannot drift apart. Precedence mirrors the sort axis:
+ `unknown` > `dotted` > unmaterializable.
+
+ **What stays accepted:** a dotted path into a structured/JSON head
+ (`{'address.city': 'Beijing'}`) — deliberately unjudged per the ruling, since
+ it genuinely works on two of three backends; array-valued and file heads, for
+ the same reason; the nested-relation OBJECT form `{ owner: { region: 'NA' } }`;
+ and every undotted spelling, byte-identically.
+
+ ## FROM → TO
+
+ ```ts
+ // before — 200, zero rows, indistinguishable from an empty table
+ await engine.find('task', { where: { 'project_id.name': 'Apollo' } });
+
+ // after — 400 INVALID_FIELD naming 'project_id.name', with the remedy:
+ // denormalise the value onto a stored field of the queried object and
+ // filter that (or, to test the relation itself, filter the head field):
+ await engine.find('task', { where: { project_id: apolloId } });
+ ```
+
+ There is deliberately no automatic rewrite: the platform cannot invent the
+ stored column the remedy prescribes, and it must not join or post-filter
+ instead — the drivers have already applied `limit`/`offset`, so a post-hoc
+ predicate would filter an arbitrary page.
+- 326f5de: fix(objectql): a TRUE `readonlyWhen` no longer strips hook-derived values — the conditional strip judges only API-boundary callers (#9107)
+
+ `stripReadonlyWhenFields` runs AFTER the before-phase hooks and was keyed on
+ `name in data` over the POST-hook payload, so a value a `beforeUpdate` hook
+ computed was judged exactly like a key the caller forged. Unlike the static
+ `readonly` strip immediately beside it, it carried no `isSystem` exemption
+ either — so a field locked by a TRUE predicate had **no server-side write path
+ at all**: a hook derived it and the strip deleted it; a cron or plugin wrote it
+ with `{ context: { isSystem: true } }` and the strip deleted that too.
+
+ Net effect before this change: the derived-field pattern (hook-computed column)
+ and a conditional form lock could not coexist on one field. An author wanting
+ "visible on the form but locked" **and** "recomputed by a hook" had no
+ spec-compliant spelling, and the failure was silent behind an HTTP 200.
+
+ Measured downstream (steedos-labs/os-project-titanwind-ehr#1446):
+ `equipment.next_maintenance_date` is hook-derived (last maintenance date + cycle
+ days) and declared with an always-true `readonlyWhen` to render
+ visible-but-locked. After a maintenance sign-off the recompute never landed, and
+ a scheduler keyed on that date regenerated the same maintenance plan on every
+ scan — a user-visible duplicate-plans loop, diagnosed only by reading the
+ engine's strip order in the dist bundle.
+
+ The conditional strip now carries the exact key discipline #5591 gave the static
+ one, on **both** branches (by-id and multi-row) off one engine-entry snapshot: a
+ key is judged only while it is still an own property of the caller's payload as
+ it arrived at engine entry AND still holds that caller's value by `Object.is`. A
+ key a hook added, or overwrote, is a server value and survives.
+
+ **The API-boundary lock is unchanged, and a caller cannot launder a write
+ through the hook phase.** To reach the exempt side of either test a value must
+ differ from what arrived at engine entry — which only server code can arrange. A
+ client that echoes the locked key back is stripped exactly as before; if a hook
+ overwrites that key, what persists is the **hook's** value, never the client's.
+ `isSystem` is still deliberately NOT an exemption for `readonlyWhen`: a state
+ lock that any system-context write could bypass would not be a state lock (the
+ frozen paid-invoice-lines case depends on it).
+
+ What moves for callers:
+
+ - A `beforeUpdate` hook may now write a field locked by a TRUE `readonlyWhen`.
+ This is the sanctioned channel for a conditionally-locked derived field.
+ - `onFieldsDropped` no longer reports such a key under `readonly_when` — it is
+ written, not dropped, so reporting it would make the observability seam lie.
+ - `strictReadonlyWrites` no longer refuses a write whose only `readonlyWhen`
+ "drop" was a hook's own value; a caller-supplied locked field is still refused.
+ - The `ERR_READONLY_FIELD_REJECTED` refusal message's `readonlyWhen` remedy
+ clause now reads "every **API-boundary** caller, isSystem included" and names
+ the hook path. The error `code` is unchanged; a pin on the exact message text
+ moves with it.
+
+ If an app relied on the strip discarding a hook's own write to a locked field,
+ that write now lands — remove the hook assignment, or narrow the predicate.
+- 1258dca: Restore the #4757 unscoped multi-delete refusal on `sys_attachment` through the wired engine (#9719).
+
+ `ObjectQL.registerHook` gains an opt-in `dispatchUnscopedMultiDelete` declaration (valid on `beforeDelete` registrations only — anything else is refused at registration): when a `multi: true` delete arrives with no `where` at all (absent or `null`), the engine's predicate path dispatches the whole-operation context ONCE to declaring registrations — before any matched row is resolved, zero-match included — so a guard about the operation's shape can refuse it. Binding `input.id` on that context is refused (`HookTargetRebindError`, path `'unscoped-multi'`). Undeclared registrations, scoped deletes (including the match-all `where: {}`), and by-id deletes see no new dispatch.
+
+ The `sys_attachment` access guard declares the flag, so its documented refusal of a predicate-less multi-delete fires again with its declared envelope (`ATTACHMENT_DELETE_DENIED`, HTTP 403): since the per-row dispatch contract (#5038/#5574) that branch was unreachable, and a predicate-less `multi: true` delete quietly removed every row the caller happened to be entitled to. System-context and context-less programmatic deletes bypass the guard exactly as before.
+
+### Patch Changes
+
+- a751f7d: fix(objectql): a cascade-delete dependents probe that FAILS no longer skips the referential guard — only an unprovisioned child table is read as "no dependents" (#8895)
+
+ `ObjectQL.cascadeDeleteRelations()` probes each child relation
+ (`find(child, { where: { fk: id } })`) to decide what the parent's delete must
+ do. That probe **is** the referential-integrity guard, and it sat behind a bare
+ `catch { continue; }` — so **any** failure of it (a connection drop, a timeout,
+ a permission denial, a query error, a missing column) was indistinguishable
+ from "this child has no rows":
+
+ - a `deleteBehavior: 'restrict'` relation never refused the delete, so a delete
+ the integrity rules say must be **refused was allowed through**;
+ - `set_null` / `cascade` never ran, so child rows that should have been nulled
+ or removed were **left orphaned**, pointing at a parent that no longer exists;
+ - nothing was logged and nothing was returned, so the caller was told the
+ delete **succeeded**.
+
+ That is fail-OPEN on an integrity guard: the read never happened and the answer
+ "there are none" was invented for it (ADR-0110 D3 — "the probe found nothing"
+ and "the probe could not run" are different facts, and here they have opposite
+ meanings).
+
+ The `catch` is not removed; it is **discriminated by error type**, through the
+ same shared `isMissingTableError` predicate (`@objectstack/metadata/errors`)
+ that `seedAutonumber` and `resolveFileReferences` already use:
+
+ - **benign, unchanged** — the child object is registered but its **table** was
+ never provisioned (schema sync not run yet). It cannot hold a row referencing
+ anything, so zero dependents is the truth and the relation is skipped exactly
+ as before.
+ - **everything else now surfaces** — the delete fails with the probe's own
+ error, envelope intact, and nothing is written. A guard that could not be
+ **evaluated** must not silently pass.
+
+ No new error code, no new response field: the caller receives the failure the
+ probe itself raised. The only behavioural change is that a delete which used to
+ report success over an unreadable child relation now reports the failure that
+ made the relation unreadable.
+- eccb8b2: 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.
+- 650cd3d: 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.
+- b735507: 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.
+- 91c6c28: 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.
+- 4e71ae1: lifecycle: a failed governance row-count probe is no longer indistinguishable from a quiet object
+
+ `LifecycleService.checkGovernance()` probed each declared object's row count and swallowed
+ every failure with a bare `catch { continue }`. A driver outage therefore read exactly like
+ an object with nothing to alert on: no `quota-exceeded`, no `growth`, nothing logged, and
+ nothing in the sweep report — and because the failed object also dropped out of the count
+ map that becomes the next sweep's baseline, the next sweep could not alert on growth for it
+ either.
+
+ The probe now discriminates by error type through the shared `isMissingTableError`
+ predicate. An unprovisioned table is truthful emptiness and stays silent; every other
+ failure is reported per object in the sweep report's existing `errors` list and logged at
+ `warn`, both naming the lost growth baseline. No new report field, no new error code, and
+ the sweep is still isolated — one object's failed probe never costs the others their
+ governance.
+- 739fe5b: fix(spec): enforce the list-comparand rule at the shared compile face, so a scalar `in`/`nin` no longer reaches a driver (#9228)
+
+
+
+ `FieldOperatorsSchema` has always declared `$in` / `$nin` as `z.array(z.any())`
+ and `$between` as `z.tuple([min, max])`, and #5869 / PR #6209 built the gate that
+ enforces it — but only at `@objectstack/objectql`'s lowering seam. That covers
+ every query reaching a driver **through the engine** and nothing else. A caller
+ that lowers a filter with `parseFilterAST` and calls a driver directly — an
+ embedder, and this repo's own driver conformance suites — met no gate at all:
+ `parseFilterAST([['name', 'notin', 'alpha']])` returned `{ name: { $nin:
+ 'alpha' } }`, a shape the contract forbids, and handed it over.
+
+ That path was carried by mingo's own coercion of a non-array `$in`/`$nin`
+ operand. mingo 7.2.3 removed the coercion, so from 7.2.4 on the same input
+ escapes as an unhandled third-party `TypeError: b.filter is not a function` —
+ no `code`, no `status`, no field name — straight to the caller. It is the sole
+ failure blocking the `mingo` 7.2.2 -> 7.2.4 bump.
+
+ **Fixed at the shared face, with exactly one implementation.** The rule now
+ lives in `@objectstack/spec`'s `data/filter-comparand-shape.ts`, the same place
+ the comparand-TYPE door (#7872 / PR #8234) was promoted to for the same reason
+ ("enforced once at the shared compile face for all five drivers"), and
+ `parseFilterAST` runs it on everything it returns — shape first, then type, the
+ order the engine's own seam already applied. `@objectstack/objectql`'s
+ `assertListComparandShapes` is now a delegating wrapper whose only remaining job
+ is the engine's `find('deal'): ` caller prefix; no driver was patched (both
+ driver families are under the #5499 investment freeze).
+
+ **The accept/reject delta is narrow and one-directional.** Newly refused, with
+ the ADR-0112 `INVALID_FILTER` / 400 envelope: a non-array `$in` / `$nin`
+ comparand and a non-`[min, max]` `$between` comparand, reaching a driver via a
+ direct `parseFilterAST` call. Nothing else changes — every filter the engine
+ accepted still lowers byte-identically, `$in: []` / `$nin: []` remain legitimate
+ declared predicates, list MEMBER types stay unjudged here, and a field spec with
+ no `$` key is still not descended into. The same inputs were already refused
+ with the same envelope on every engine verb and at the REST ingress, so no
+ authored metadata in the repo or in `objectui` produces a shape that newly
+ fails: a survey of `examples/**`, `content/docs/**`, fixtures, seeded platform
+ objects and objectui's view definitions found every membership rule already
+ carrying an array.
+
+ `parseFilterAST` gains an optional second argument, `context` — the caller
+ prefix both doors in `@objectstack/spec` already take. It is additive and
+ defaulted; existing calls are unaffected.
+- 4dfa369: Keep a caller's value out of the server log when MySQL reports a duplicate entry
+
+ The driver-fault redaction added for #8682 replaces the bound statement in a logged
+ write fault and keeps the database's own diagnostic, because that diagnostic names the
+ failing identifier an operator needs. On MySQL's `ER_DUP_ENTRY` (1062) that premise does
+ not hold: the template is `Duplicate entry '' for key ''`, so the
+ conflicting value is in the diagnostic rather than in the statement and survived the cut.
+
+ The tail is still kept — including `for key ''`, which is the answer to "which
+ constraint?" — and only the value slot is replaced:
+
+ ```
+ before Duplicate entry 'acme@example.com' for key 'crm_account.email' [statement and bound values redacted]
+ after Duplicate entry [value redacted] for key 'crm_account.email' [statement and bound values redacted]
+ ```
+
+ Also closed: a value spelled with `" - "` in it used to leave a fragment behind, because
+ the statement cut takes the last separator and that separator was inside the value.
+
+ Identifier-bearing diagnostics on every dialect are unchanged, the rethrown error is
+ untouched, and no HTTP response moves — this narrows one log slot only.
+- 5e2f594: fix(objectql): `ObjectQLPlugin`'s three registry reads stop inventing an empty registry — one of them silently skipped schema sync for every object at boot (#9285)
+
+ `ObjectQLPlugin` read the registered object set in three places, all spelled
+ `this.ql.registry?.getAllObjects?.() ?? []`. That expression folds three
+ different facts into one value:
+
+ 1. the registry answered, and holds no objects;
+ 2. the engine exposes no `registry` at all;
+ 3. the registry exposes no `getAllObjects` — a **structural** omission that
+ never throws, so it is invisible precisely when it is wrong.
+
+ Only (1) is truthfully *"no objects"*. #8895 ruled this family **discriminate or
+ propagate**; #9002 and #9154 applied it to the two delete-cascade seams and the
+ roll-up summary index. This closes the same shape in the plugin, where the
+ consequential seam is at **boot**.
+
+ The three seams get three different answers, and the difference is the fix:
+
+ - **`syncRegisteredSchemas` — propagates.** Its next line is
+ `if (allObjects.length === 0) return;`, so an invented empty answer meant **no
+ registered object's schema was synced to any driver** — no table created, no
+ column added — silently, at boot, with the plugin reporting a clean start.
+ Failing the boot is more truthful than starting against a store whose DDL
+ never ran. On the `metadata:reloaded` path the existing caller already catches
+ this and reports it at `error` (#4632), so propagation there is a loud
+ durability report rather than a dead kernel.
+ - **`reconcileFederatedBindings` — reports at `error`, then degrades.** The pass
+ exists to *name* the federated objects it could not bind ("a boot with nothing
+ to report says nothing"), so an unreadable registry making it report nothing
+ was exactly the silence it was written to prevent. It stays exception-proof:
+ it is a post-hoc reconciliation run after every `start()`, deliberately not a
+ boot gate.
+ - **`runGovernanceInventory` — reports at `warn`, then skips.** This seam
+ carried **two independent swallows** (`?.()` *and* a wrapping
+ `try { … } catch { return [] }`), so a *throwing* registry was
+ indistinguishable from an empty one. Feeding the audit an invented empty
+ object set is worse than silence: with no objects, every handler declared *on*
+ an object reconciles as an "undeclared handler … REFUSED at dispatch", so an
+ unreadable registry accused a healthy deployment. The inventory is warn-only
+ and exception-proof by contract, so it reports and skips instead of
+ propagating, and leaves its report fingerprint untouched so the next
+ successful run is not suppressed as "unchanged".
+
+ All three now read through one shared helper that throws rather than inventing,
+ naming the consequence; a registry that *throws* propagates its own error
+ verbatim.
+
+ This is a **structural** close, not a live defect — re-derived on this tree:
+ `SchemaRegistry.getAllObjects()` is a walk over in-memory `Map`s calling
+ `resolveObject()`, which returns `undefined` on every failure branch it models
+ and never throws, and `ObjectQL.registry` is a getter over a field-initialized
+ `SchemaRegistry`, so for a real engine neither optional link can short-circuit.
+ The reach that is real is a duck-typed `ql` — an incomplete test double, which
+ #9154 measured shipping in nine suites at once.
+
+ The `objectsRegistered` count in the `ObjectQL engine started` info log is
+ deliberately unchanged: a wrong `0` there costs one advisory line and no data.
+- 855591f: fix(objectql): a failed `sys_organization` probe stops reading as "this install has no organizations" (#9261)
+
+ `probeInstallOrganizations` — the read the #8844 system-write organization
+ resolution decides on — sat behind a bare `} catch { ids = [] }`. Every failure
+ answered with the count that means *none*, and `resolveSystemWriteOrganization`
+ maps 0 / 1 / 2+ organizations to *proceed unstamped* / *stamp the derived id* /
+ *refuse*. So one transient probe failure silently skipped **both** halves of the
+ 2026-08-15 ruling:
+
+ - on a `single`-posture install that really has one organization, system-context
+ inserts (a hook, a cron tick, a `runAs: system` flow) landed **unstamped** —
+ filing the row under the `__global__` pseudo-tenant and forking exactly the
+ per-organization autonumber counter and partitioned unique index the ruling
+ exists to protect;
+ - on a multi-organization install, the refusal the ruling mandates
+ (`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`) **never fired** — fail-open on a
+ guard that must be loud.
+
+ Aggravated by the memo: the invented answer was cached in
+ `organizationProbeMemo`, so the outage's consequence outlived the outage — every
+ later system write inherited "no organizations" until an organization write
+ happened to clear it.
+
+ The probe now discriminates by error TYPE, the disposition ADR-0110 D3 requires
+ ("the probe found nothing" and "the probe could not run" are different facts):
+
+ - **benign** — `sys_organization` routes but its table was never provisioned
+ (schema sync not run yet). It cannot hold a row, so zero really is the count,
+ and first boot still proceeds unstamped. Asked through the shared
+ `isMissingTableError` predicate (`@objectstack/metadata/errors`), the same call
+ the file's sibling read seams make, never a hand-rolled code test.
+ - **everything else** — connection loss, pool exhaustion, a timeout mid-boot, a
+ datasource that never connected, a permission denial — propagates with its
+ envelope intact, and is **not memoised**. The write that asked fails loudly
+ instead of being filed under a guessed topology, and the next write re-probes
+ rather than inheriting the guess. No new error code and no new response field.
+
+ Measured rather than assumed: the old comment's "`sys_organization` may not be
+ registered at all (a lean embedding, a bare-kernel test)" is not a second benign
+ cause. An object missing from the registry does not fail the read at all on a
+ driver that tolerates an unknown table (`find` returns `[]` through the normal
+ path), a strict driver surfaces it as the missing table above, and an engine
+ with no driver fails the write on its own object one frame before the probe runs.
+- cd455c8: docs: four published READMEs stop documenting symbols and call sites that do not exist (#9544)
+
+ All four packages ship `README.md` in their `files` array with `private` unset, so these
+ are the pages npm renders. Each finding was re-measured against the **built `.d.ts`**, not
+ against source, because that is what a consumer resolves through the `exports` map.
+
+ - **`@objectstack/driver-sql`** — `import type { IDriver } from '@objectstack/spec'` named
+ a type that exists **nowhere in the repository** (0 hits across every package's `src`
+ and `dist`). The real contract is `IDataDriver` on `@objectstack/spec/contracts` — the
+ one `SqlDriver` actually declares (`export class SqlDriver implements IDataDriver`). The
+ adjacent operation list was corrected too: the method is `create`, not `insert`.
+
+ - **`@objectstack/mcp`** — `DriverSql` has never existed (the export is `SqlDriver`), and
+ the README then called `DriverSql.configure({...})` on it. Renaming alone would have
+ been wrong twice over: `SqlDriver` has **no static `configure` either**, and `driver:`
+ is not a key of `defineStack` at all. The example now declares a datasource the way the
+ shipped templates do. `MCPServerPlugin.configure({...})` — five call sites — becomes
+ `new MCPServerPlugin({...})`, the form the class's own JSDoc and every in-repo caller
+ use. The documented options block claimed `serverName`, `autoRegisterTools`,
+ `autoExposeObjects`, `enableStreaming`, `port` and `debug`; the real
+ `MCPServerPluginOptions` is `name`, `version`, `transport`, `autoStart`, `instructions`,
+ and the env switches are named instead.
+
+ - **`@objectstack/objectql`** — `registerObject` is an **instance** method, so
+ `SchemaRegistry.registerObject(...)` on the class could never run. The example now
+ reaches it through the engine's registry and states the real parameter order
+ (`schema, packageId, namespace?`).
+
+ - **`@objectstack/spec`** — the protocol package's own front page imported
+ `MCPServerConfigSchema` from `@objectstack/spec/ai`, which exports `MCPServerRefSchema`.
+ A rename by itself would have swapped a broken import for a broken **parse**: the
+ documented payload was built for a schema that does not exist, and
+ `MCPServerRefSchema.safeParse` rejects it (`transport` is an enum of
+ `stdio | http | websocket`, not an object, and `endpoint` is required and was absent).
+ The example is now a payload that parses green, and the page says plainly that tools,
+ resources and prompts are derived from metadata at runtime rather than authored there.
+- 21995d7: fix(objectql): the redactor's end-of-message head invariant becomes a load-time guard and a type, not a doc comment (#9359)
+
+ #9275 made the driver-fault statement cut **template-aware**: a separator standing
+ immediately before a *measured* diagnostic head is the true cut point wherever it falls,
+ so the head survives and the caller's value is dropped whole. That amendment is safe
+ because of exactly one property:
+
+ > **Only an end-of-message template may declare a head.**
+
+ That property is what bounds a hostile value's influence to **over-redaction** — a value
+ spelling a known head can suppress a real diagnostic and show a forged one, but it cannot
+ make a value leak. Give a `head` to a family with a **right anchor** and the same cut keeps
+ everything after that anchor, which on such a cut is statement, which is caller values.
+
+ Until now the invariant was held by **prose plus one behavioural case** that forges the
+ heads the table declares *today*. Nothing stopped a future author adding a head-bearing
+ row whose `whole` is not end-anchored — the single shape that turns the amendment into a
+ leak surface.
+
+ **The argument for closing it structurally comes from this file's own history.** The head
+ note once claimed leak-freedom rested on taking the LAST matching head as well as on
+ end-of-message. Ablated: with the cut changed to take the FIRST head, all 50 cases in the
+ suite stayed green — an end-of-message pattern matches only once, from its earliest
+ position. A documented property about this very mechanism was wrong for weeks of reading
+ and fell only to an ablation. A doc comment is not a guard.
+
+ The invariant is now held in two places a future author cannot write past:
+
+ - **The type.** `ValueBearingTemplate` is a union of `AnchoredTemplate` (`tail?`, and
+ `head?: never`) and `EndOfMessageTemplate` (`head`, and `tail?: never`), so a row
+ carrying both no longer compiles.
+ - **`assertHeadBearingTemplatesAreEndAnchored()`**, called at module load over
+ `VALUE_BEARING_TEMPLATES` — the `assertMetaUrlSpellingsAgree()` shape. For every row
+ that declares a `head` it requires the `whole` to end with `$()`, to carry no `m` flag
+ (under `m`, `$` is end of LINE, so an "end-anchored" template would stop at the first
+ newline of a multi-line dump and leave the rest standing) and to have exactly two
+ capture groups (`redactDiagnosticValues` reads `whole[1]` and `whole[2]` by index).
+
+ **No redaction behaviour changes.** The statement cut, the head set and the templates
+ themselves are untouched; the guard only refuses tables that could not have been correct.
+ The existing behavioural case that forges each head is kept — it is evidence, and the
+ guard is additional rather than a replacement.
+
+ The guard is proved to FIRE rather than merely to exist: eight new cases drive each shape
+ it rejects (right-anchored `whole` with a head, the `m` flag, a wrong group count, a bad
+ row behind a good one) and pin that it does **not** fire on the shipped rows or on
+ right-anchored rows that correctly take a `tail`. Reverse-verified both legs — a
+ head-bearing right-anchored row added to the shipped table makes every test in the package
+ fail at module load, and a row carrying `head` and `tail` together fails `tsc`.
+- 6a5e6ad: fix(objectql): `[]` no longer satisfies `required` on a multi-value field — the #9447 ruling's enforcement half (#9476)
+
+
+
+ Per the #9447 maintainer ruling (2026-08-18): `required` on a multi-value
+ field means **non-empty array**. The empty set is representable — it reads
+ back as `[]`, never `null` — so `required` judges emptiness.
+
+ Before this, `validateRecord` judged `required` through `isMissing`, which
+ knows `undefined` / `null` / blank strings — an explicit `[]` sailed through
+ on both INSERT and UPDATE while `null` was correctly rejected. Now:
+
+ - INSERT: `[]` on a required multi-value field is rejected — 400
+ `VALIDATION_FAILED`, field code `required`, the same envelope a missing
+ value already got.
+ - UPDATE: a SUPPLIED `[]` is an explicit clear — rejected with the distinct
+ `required_cleared` sentence (wire code `required`), exactly like an
+ explicit `null`. An omitted field still never 400s — legacy rows rest.
+ - Scope is the spec's own multi-value predicate (ADR-0104 D1):
+ inherently-multi option types plus multi-capable types flagged
+ `multiple: true`. Structured-JSON fields are untouched — `[]` there is a
+ document, not an emptied set. Populated arrays and non-required
+ multi-value fields are untouched.
+- b2a451f: fix(objectql): the roll-up summary index's registry read propagates, and a failed read is never cached as an empty index (#9154)
+
+ `ObjectQL.buildSummaryIndex()` opened with
+
+ ```ts
+ try { objects = (this._registry as any).getAllObjects?.() ?? []; } catch { objects = []; }
+ ```
+
+ and `ensureSummaryIndexes()` MEMOIZES what that build returns, stamped with the
+ registry's current `objectRevision`. So a read that could not run was answered
+ with an invented *"no object declares a roll-up"* — and then remembered as if it
+ had been measured. `recomputeSummaries()` consults that index after every insert,
+ update and delete to decide which parent roll-ups a child write must recompute,
+ so an empty index means no roll-up is ever recomputed: every parent summary field
+ keeps a stale value, nothing is logged, and every write reports success.
+
+ Two changes, because the cache is the half that made this worse than the
+ identical seams fixed in #9002:
+
+ - **The read propagates.** Same family as #8895 (*discriminate or propagate*) and
+ #9002, same reasoning: discrimination needs a benign failure class and there is
+ none — an unreadable registry is never truthfully "no roll-ups". Both halves of
+ the swallow are gone, the `catch` and the optional call `?.()`, which absorbed a
+ registry that does not implement `getAllObjects` at all — the structural
+ omission that never throws and is therefore invisible.
+ - **A failed build leaves no cache entry.** `objectRevision` moves only on a
+ metadata mutation (`registerObject`, `unregisterObject`,
+ `unregisterObjectsByPackage`, `removeObjectOverlay`, `invalidate`,
+ `invalidateAll`, `reset`) and never on a data write, so the invented emptiness
+ outlived the condition that caused it — a steady-state deployment performs none
+ of those, leaving every parent roll-up frozen until a restart or an unrelated
+ publish. The build now runs to completion into a local before anything is
+ published to the instance, the revision stamp is written last, and a throw
+ clears any cached index and resets the stamp before rethrowing unchanged: the
+ next call rebuilds. *A poisoned cache entry must not survive the read that
+ poisoned it.*
+
+ **No shipped behaviour changes.** `SchemaRegistry.getAllObjects()` is a walk over
+ in-memory `Map`s calling `resolveObject()` — which returns `undefined` on every
+ failure branch it models — over a fold that is spreads and comparisons. No I/O,
+ no driver, no `throw` on the measured path, re-derived on today's tree. 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 silently freezing
+ every roll-up in the deployment.
+- ff08691: fix(engine-core): a system-context insert on a tenant-scoped object resolves the install's organization the way a session write does, or is refused — the runtime producer of the autonumber fork #8686's backfill cannot reach (#8844)
+
+
+
+ #8686 fixed **one** producer of untenanted rows — the seed loader — and shipped
+ a one-shot backfill for what it had already written. This card is the **other
+ producer, which is still running**: an ordinary application write made under a
+ system execution context (a hook, a scheduled job, a custom endpoint, a
+ `runAs: system` flow). A backfill cannot reach it, because it mints a fresh
+ duplicate on every tick — which makes #8686's repair **self-undoing on any
+ install with server-side automation**, i.e. every business app.
+
+ **Measured on 17.0.0 GA**, a single-tenant EHR/MES install with ~44 autonumbered
+ objects: two records, same object, same install, the **same** value on a field
+ the app declared `unique`, with no error and no warning. The `notification` case
+ shows both producers side by side — `NT-00002 .. NT-00011` each existing twice,
+ copy A written by the "maintenance overdue" cron job, copy B by a user action.
+
+ **Mechanism.** A session write carries the caller's active organization, the SQL
+ driver stamps it onto the row (`injectTenantOnInsert`), and the autonumber
+ counter reads it back off the row (`fillAutoNumberFields`, resolving
+ `row[tenantField] ?? options.tenantId ?? null`). A system-context write carries
+ none, so the column lands `NULL` and the counter files the row under the
+ `__global__` pseudo-tenant. One object then runs two counters that cannot see
+ each other, each correct within its own scope, and the partitioned unique index
+ — `(COALESCE(organization_id, '__global__'), )`, ADR-0120 D3 — cannot see
+ across the two partitions either.
+
+ ⛔ **Not a counter bug**, and not fixed by making the allocator smarter: both
+ counters are already correct within their own scopes (the reasoning #8686
+ recorded, unchanged). The defect is upstream of the counter.
+
+ **The fix, per the 2026-08-15 maintainer ruling (Option 1)** — a system-context
+ write resolves the install's organization the way a session write does, at the
+ engine's stamp resolution, so every driver is covered at the source (which
+ matters here because `fillAutoNumberFields` is duplicated in `driver-sql` and
+ `driver-turso`; neither driver changed):
+
+ - **Single-tenant, exactly one organization ⇒ derive and stamp.** The
+ `__global__` fork stops being minted by hooks, cron and system endpoints.
+ - **Multi-organization ⇒ carry an explicit organization or be REFUSED LOUDLY**,
+ never silently defaulted. A walled posture (`group` / `isolated`), or a
+ `single` posture whose data holds several organizations, has no derivable
+ answer — the refusal is `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (500,
+ registered in the ADR-0112 ledger), thrown before anything reaches the driver,
+ and its message names the condition, what would otherwise have been written,
+ and both remedies.
+ - **Already-minted duplicates are reported, never rewritten** — the #8686
+ posture, ruled again here. Nothing in this change renumbers anything.
+
+ **Three populations are outside the rule by construction, not by exemption**, so
+ that the refusal cannot break unattended automation that was never at risk:
+ objects with no organization column, objects declaring `tenancy: { enabled:
+ false }` (ADR-0066 — the *declared* way to hold org-less rows, rather than a
+ per-write bypass flag) and federated objects (ADR-0015); the platform namespaces
+ `sys_` / `cloud_` / `ai_`, whose rows are deliberately global (#8672's reasoning,
+ which this ruling confirms holds for platform objects and does **not** generalize
+ to application objects); and any write that already carries an organization — on
+ the execution context, on the record, or stamped by a `beforeInsert` hook.
+
+ **First boot is untouched:** before any organization exists there is nothing to
+ derive and no second partition to fork away from, so those rows still land
+ org-less for #8686's `sys_organization`-insert handoff to adopt.
+
+ Scoped to **insert**, deliberately: the ruling's yardstick is "the way a session
+ write does", and stamping the organization is an insert-side mechanism — an
+ update neither stamps it nor can fork a counter.
+- 402c125: fix(objectql): a temporal filter comparand the platform cannot interpret is refused at the engine door instead of answering 200 with zero rows (#8690)
+
+
+
+ A `datetime` / `date` / `time` field filtered with a bare string the platform
+ cannot read — `last_30_days`, `not-a-date-at-all` — was bound **as written**
+ all the way to the driver, where the comparison is false for every row. The
+ caller received `HTTP 200`, an empty result set, and nothing to indicate the
+ filter was meaningless. An unknown `{placeholder}` in the same position was
+ already refused loudly (`FILTER_TOKEN_UNKNOWN` / 400, listing the resolvable
+ tokens), so one API answered two shapes of unusable comparand two different
+ ways.
+
+ It is concretely reachable rather than theoretical: `last_7_days` /
+ `last_30_days` / `last_90_days` are **declared preset names** in the dashboard
+ schema. The shipped console lowers them to `{N_days_ago}` macros before they
+ reach the API, so the console path was always safe — but a saved report, an
+ integration, an MCP client or an AI-authored query sends the preset name itself
+ and got a silent zero. An empty chart is the hardest failure to debug: it is
+ indistinguishable from "there is genuinely no data".
+
+ Such a comparand is now refused at the ObjectQL engine's single filter
+ collection point, with `code: 'INVALID_FILTER'` and `status: 400`, naming the
+ field, the value, the key path and the spellings that would work. That seam is
+ the one place holding the caller's comparand and the field's **declared type**
+ at the same moment, and every verb (`find` / `findOne` / `count` / `aggregate`
+ / `update` / `delete`) and both filter spellings (the array sugar and the
+ lowered condition) pass through it, so all four backends inherit one answer
+ rather than four. `NativeSQLStrategy` additionally **declines** such a query so
+ the raw-SQL analytics path falls through to that door instead of binding the
+ value into its own statement.
+
+ Deliberately unchanged, each by ruling: a `{placeholder}` keeps its existing
+ refusal one layer down (the door runs before token resolution and steps around
+ them, so `{30_days_ago}` still resolves normally); non-string comparands are
+ untouched (a number is epoch milliseconds, a `Date` is an instant); and the
+ **empty string** keeps today's behaviour exactly — it binds as `''` and matches
+ every non-null row, which is a separate question that remains its own card.
+- 7c2f386: fix(objectql): the tenant-scope index follows the WALL's derivation, so an object that opts out with `systemFields: false` while declaring its own `organization_id` stops running the wall predicate unindexed (#8608)
+
+
+
+ Two places answered *"is this object tenant-scoped?"* and read different
+ declarations. The platform's tenant-scope index was gated on the spec's
+ **injection plan** (`resolveInjectedSystemColumns(...).tenant`), while
+ plugin-security's Layer 0 wall derives `tenancyDisabled` from exactly two
+ clauses:
+
+ ```ts
+ tenancy.enabled === false || systemFields.tenant === false
+ ```
+
+ `systemFields: false` — the hard object-level opt-out — is in the plan and in
+ neither of those clauses. So an object using that opt-out **while declaring its
+ own `organization_id`** had `organization_id = ` AND-composed onto
+ essentially every read, with no index behind it: the deployment's hottest
+ predicate, unindexed. Not a security hole — isolation still held; it was slow,
+ not wrong, which is why nothing surfaced it.
+
+ **Both halves were measured end to end** rather than read off the source. On the
+ pre-fix tree, for one such object, the registry answered `indexes: null` while
+ `SecurityPlugin#getReadFilter` answered `{ organization_id: 'org-1' }` for an
+ ordinary member.
+
+ The wall's derivation is authoritative and the index now follows it: the index
+ is declared when tenancy is not disabled by the wall's two clauses **and** the
+ object carries `organization_id` — whether the platform provisions the column or
+ the author declared it. `managedBy: 'better-auth'` is deliberately not re-added
+ as a third clause, because the wall does not read it either; the one shipped
+ platform object whose answer changes is `sys_member`, which is walled on
+ `organization_id` and whose only tenant-leading index was the composite
+ `['organization_id', 'user_id']`.
+
+ Unchanged, and pinned beside the fix: `systemFields.tenant: false` and
+ `tenancy.enabled: false` still declare no index (the wall composes no predicate
+ there, so an index would serve nothing), a single-tenant deployment still
+ declares none at all, an author's own tenant index still suppresses the
+ platform's, and the hard opt-out still injects no platform columns — only the
+ index decision was ever owed at that exit.
+- 8a9e7f4: Refuse undeclared fields on insert at the schema, and keep bound values out of the write-path logs (#8682)
+
+ **A single mistyped field name in a client request no longer writes an entire row's values to disk.** A driver-level write fault is logged by prefixing the fully bound SQL statement — values inlined — to the database's own message, and the logger serializes both `message` and `stack`, so the statement was written twice at ERROR level. Confirmed with planted canaries: the row's values landed in the log alongside the organization id and the acting user id. The insert, update and delete loggers now write the database's own diagnostic — which still names the failing column and the object — with the statement and its bound values cut from both fields. The level, the message and the entry itself are unchanged: a driver fault nobody can debug would be a worse outcome than one logged too loudly.
+
+ **An undeclared field is now refused by the object's field map, before anything runs for a request that was already going to be refused.** Previously an unknown key was caught only at the very end, by the driver, after an id, an auto-number, a normalized name, owner/creator resolution, the column defaults and the app's `beforeInsert` hooks had all been produced for it. The auto-number was the durable damage: the refused request consumed a sequence value and left a permanent gap in a document number an end user reads. `insertMany` now culls such a row per row instead of letting it fail the whole batch.
+
+ The client-facing answer is deliberately unchanged — the same `400 INVALID_FIELD`, with the same message and the same `field` / `object` — and the rethrown error is untouched, so only what reaches the log has moved. Objects whose field map is absent or empty get no verdict at all, and `id` / `created_at` / `updated_at` stay accepted even when a declaration omits them, matching what the read path already tolerates; in every one of those cases the driver remains the backstop it has always been.
+- 3d0ded8: Refuse undeclared fields on update at the schema, before the `beforeUpdate` hooks run (#8738)
+
+ **An undeclared key on `engine.update(...)` is now refused by the object's field map, before anything runs for a request that was already going to be refused.** Previously it travelled the whole update path and was refused at the very end by the driver — measured on both branches of the verb: `driver.update` on the by-id path and `driver.updateMany` on the predicate path each received the mistyped key. The `beforeUpdate` hooks ran first, so a hook that stamps a ledger, calls out, or derives a field executed for a write that was then rejected; in the reproduction the hook's derived value travelled into the statement the driver refused.
+
+ **What a caller observes changing.** The refusal itself does not move: an undeclared update key was already rejected, and the client-facing answer is deliberately unchanged — the same `400 INVALID_FIELD` with the same message, `field` and `object`, which `@objectstack/rest` re-emits verbatim. What changes is where the refusal is decided, and therefore what the error carries **inside the process**: an in-process caller of `ObjectQL.update()` that caught the old failure saw the driver's raw error (no `code`, no `status`, its message containing the bound SQL statement) and now sees the ADR-0112 envelope (`code: 'INVALID_FIELD'`, `status: 400`) with a message naming the field. An in-process caller matching on the driver's SQL text — rather than on the envelope — is the one shape that has to change. The write no longer costs a driver round-trip either: the pre-update read is skipped along with the hooks.
+
+ The door is the same one `insert()` has carried since #8682 — one condition, one implementation, now with two callers — including its three deliberate no-opinion cases, which are unchanged and reused rather than re-derived: an absent field map, a field map the door sees as empty, and `id` / `created_at` / `updated_at` when a declaration omits them. Schema drift (a declared field whose physical column is missing) stays the driver's to refuse, as before. Nothing is widened; `declared = enforced` (Prime Directive #10) is restored on the second write verb.
+- 91c4ff5: fix(objectql): an unscoped `GET /api/v1/search` stops answering 400 when a federated object is registered and pinyin recall is on — the `__search` companion is no longer declared on objects the platform runs no DDL for (#9469)
+
+ On the **stock** showcase configuration an unscoped `GET /api/v1/search` — no
+ `objects=` parameter — answered **400** for the whole search. Measured on a real
+ boot, with the scoped query as the control:
+
+ ```
+ GET /api/v1/search?q=acme → 400 INVALID_FILTER
+ GET /api/v1/search?q=acme&objects=showcase_account → 200, 1 hit
+ ```
+
+ Nothing in the console was affected, because the console always scopes its
+ queries with `objects=`. An unscoped search is the obvious first call for a
+ direct API consumer, so the defect was reachable by every one of them and by no
+ console user.
+
+ **The mechanism, and why it is a producer bug rather than a search bug.** The
+ hidden `__search` companion column is not metadata — it is a real column the
+ platform promises to build: the SchemaRegistry declares it at object compile
+ time and the driver's `syncSchema` materializes it as an additive migration
+ (ADR-0045). On a **federated** object (ADR-0015) that promise cannot be kept.
+ The remote database owns the schema, DDL is forbidden, and the schema-sync seam
+ skips those objects outright. The declaration went on anyway, so the object
+ carried a field with no column — and `expandSearchToFilter`, which keys the
+ companion clause on the **declared** field, ORed `{ __search: { $contains: term } }`
+ into every `$search` against it. The backend then refused a statement it could
+ not compile, correctly (#8790): the predicate really could not run. From the
+ server log, verbatim:
+
+ ```
+ select * from `customers` where ((lower(`name`) GLOB lower('*acme*')) or …
+ or (`__search` GLOB '*acme*')) limit 5 - no such column: __search
+ ```
+
+ Every source-column clause was fine; only the companion named a column that does
+ not exist. The unscoped call is the one that sweeps every registered object, so
+ it is the only global-search call that included a federated object — which is
+ why scoping hid it.
+
+ **The fix** is one gate at the provisioning seam: an object carrying an
+ `external` binding gets no companion declaration. The predicate is
+ `external != null`, deliberately the **same** expression the schema-sync seam
+ already tests rather than a second question about the same fact, so the two ends
+ agree by construction — every object the sync seam declines to build a column
+ for is exactly an object the provisioning seam declines to declare one on.
+ (Asking the datasource's `schemaMode` here instead would be a second
+ implementation of one rule, and the SchemaRegistry holds no datasource
+ definitions at all, so that drift would be structural rather than merely
+ possible.)
+
+ **Scope of the behaviour change**, all of it a restoration:
+
+ - unscoped `GET /api/v1/search` returns results instead of 400;
+ - `?search=` on a federated object's own list endpoint stops refusing — the same
+ defect, on a call that never involved global search, and the reason the fix
+ lands at the declaration rather than in the global-search sweep;
+ - federated objects are searched through their source columns, as they were
+ before pinyin recall existed;
+ - pinyin recall is **unchanged** wherever the column is really built, and a
+ federated object whose remote table genuinely has a `__search` column keeps
+ its recall: the author declares that column as an ordinary field and
+ provisioning returns early on an already-present entry.
+
+ `/meta` for a federated object no longer advertises a `__search` field it could
+ never serve.
+- 682b86b: fix(objectql): a caller value containing " - " no longer eats the diagnostic's template head, and no longer leaves its own suffix in the log (#9275)
+
+ `redactStatementFromMessage` cuts the bound statement off a driver error at the
+ **last** ` - `, because a bound value may itself contain that separator and
+ cutting at the first would leave a fragment of the value standing.
+
+ When the value the DATABASE inlines into its own diagnostic also contains ` - `,
+ that reasoning inverts: the last separator lands **inside the diagnostic's
+ value**, so the cut discards the template head — the half that could not leak —
+ and keeps a suffix of the caller's data, which is the half that does. Re-measured
+ at HEAD on live PostgreSQL 16.13 with the canary
+ `SENSITIVE-CANARY-9275 - 2026 - Q3`:
+
+ ```
+ raised: insert into "t" ("age") values ($1)
+ - invalid input syntax for type integer: "SENSITIVE-CANARY-9275 - 2026 - Q3"
+ logged: Q3" [statement and bound values redacted]
+ ```
+
+ `Q3` is the caller's data, at ERROR level, which is what this neighbourhood
+ exists to prevent. Families with a right anchor (`for key …`,
+ `for column … at row N`) already recovered through their `tail` pattern; the ones
+ whose value runs to end of message had nothing to recover from.
+
+ **The cut is now template-aware.** When a separator in the message stands
+ immediately before a diagnostic head this file has measured, that separator is
+ the true cut point whatever its position: the head survives and the value after
+ it — separator and all — is dropped whole by the template that owns it. After
+ the fix the same error logs
+ `invalid input syntax for type integer: [value redacted] [statement and bound
+ values redacted]`, so the operator keeps strictly more diagnostic than before.
+
+ **Three families, not the two the card named.** `pg 22003` was left without a
+ head-gone recovery on the reasoning that an out-of-range value is a number and a
+ number cannot contain ` - `. Measured through the driver's own bind path, that is
+ false — Postgres detects the overflow while scanning digits, *before* it rejects
+ the trailing junk, so it echoes the caller's whole string:
+ `insert({ age: '99999999999 - 2026 - Q3' })` logged `Q3` too. It keeps its right
+ anchor, so it takes the #8823 anchor recovery rather than the new cut.
+
+ The trade this takes deliberately, and its bound: matching a template before the
+ cut lets a hostile value steer where the cut lands. That steering is bounded to
+ **over-redaction, never exposure** — a template may declare a head only if its
+ value runs to end of message, so a cut landing inside a statement is swallowed
+ whole by that template; and the **last** matching head wins, so a value that
+ mimics a head is cut at the mimic and cannot survive behind its own decoy. What a
+ crafted value can do is suppress a real diagnostic; that cost is asserted by its
+ own case rather than left to be discovered. The six identifier-bearing families
+ the live probe pins are untouched — over-matching deletes the diagnostic an
+ operator came for, and remains the expensive direction.
+- 6a1b45e: fix(objectql): stop logging the caller's value for four MORE diagnostic families — measured off live MySQL 8.0 / PostgreSQL 16, not read off a manual (#9160)
+
+ #8823 established that a database's diagnostic does not always name only
+ IDENTIFIERS: MySQL's `ER_DUP_ENTRY` inlines the conflicting VALUE, and
+ `redactStatementFromMessage` redacts that one slot while keeping the index name
+ an operator needs.
+
+ The list it introduced had **exactly one entry and no way to notice a second was
+ missing**. Nothing measured whether a diagnostic a driver produced carried a
+ value; the single entry got there because a human read one template closely, and
+ the standing rule (`packages/types/src/unique-violation.ts`) — a dialect's
+ spelling goes in once measured off a thrown error, never from a reading of the
+ manual — correctly prevented the list from growing on a guess.
+
+ **The instrument now exists.** `sql-driver-diagnostic-value-probe.test.ts` plants
+ a canary, raises each candidate family through the driver's own bind path against
+ the live MySQL 8.0 / PostgreSQL 16 services the `Temporal Conformance (live PG +
+ MySQL)` job already stands up, and asserts of every family — value-bearing or not
+ — **where the canary lands**: `error.message` (which `ObjectLogger.write`
+ serializes, so an exposure) or `error.detail` (which it does not). A family that
+ starts inlining a value it did not inline before is now a named red naming the
+ file to edit, instead of a silent leak.
+
+ Measured with a positive control first (`ER_DUP_ENTRY`, the known-value-bearing
+ neighbour, reproduced verbatim — without it a zero elsewhere would be
+ uninterpretable):
+
+ | dialect | family | diagnostic, verbatim | verdict |
+ |:--|:--|:--|:--|
+ | mysql | 1062 | `Duplicate entry 'CANARY' for key 'probe.uq'` | value on `message` (already encoded) |
+ | mysql | 1366 | `Incorrect integer value: 'CANARY' for column 'age' at row 1` | **value on `message`** |
+ | mysql | 1292 | `Incorrect datetime value: 'CANARY' for column 'when_at' at row 1` | **value on `message`** |
+ | mysql | 1264 | `Out of range value for column 'age' at row 1` | identifier only |
+ | mysql | 1406 | `Data too long for column 'label' at row 1` | identifier only |
+ | mysql | 1054 | `Unknown column 'zzz…' in 'field list'` | identifier only |
+ | pg | 22P02 | `invalid input syntax for type integer: "CANARY"` | **value on `message`** |
+ | pg | 22007 | `invalid input syntax for type timestamp with time zone: "CANARY"` | **value on `message`** |
+ | pg | 22003 | `value "99999999999" is out of range for type integer` | **value on `message`** |
+ | pg | 23505 | `duplicate key value violates unique constraint "…"` | value on `detail` only |
+ | pg | 23502 | `null value in column "id" … violates not-null constraint` | value on `detail` only |
+ | pg | 22001 | `value too long for type character varying(20)` | identifier only |
+
+ Both families the card named as candidates **are** value-bearing, and the
+ Postgres one is the sharper result: #8823 recorded that Postgres escapes the
+ unique-violation leak only because its value sits on `error.detail`, a field the
+ logger never serializes — *"coincidence, not a defence"*. `22P02` / `22007` /
+ `22003` put the caller's value on **`error.message`**, the field that IS
+ serialized, so the coincidence does not cover them.
+
+ The one-off regex pair is now an enumerable `VALUE_BEARING_TEMPLATES` table, one
+ row per measured family, each citing the live server that produced it. Every
+ identifier-bearing tail is still kept whole — over-matching deletes the
+ diagnostic an operator came for, which is the expensive direction #8682 paid to
+ avoid, and the six identifier-only families above are pinned against exactly that
+ regression.
+
+ **Known residue, measured and deliberately not closed here:** when the caller's
+ value itself contains ` - `, the statement cut lands inside it and eats the
+ template head. Families with a right anchor (`for key …`, `for column … at row
+ N`) recover; the two whose value runs to end of message (pg 22P02/22007, mysql
+ 1292's `Truncated incorrect …` spelling) have no anchor and leave a suffix
+ standing. Closing that requires the cut itself to become template-aware — a
+ change to #8682's contract, filed rather than decided.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [e374b4d]
+- Updated dependencies [5047cb8]
+- Updated dependencies [9aa8890]
+- Updated dependencies [40d5b2d]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [13d7864]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2d0af57]
+- Updated dependencies [7337f30]
+- Updated dependencies [420804d]
+- Updated dependencies [177442d]
+- Updated dependencies [950bd94]
+- Updated dependencies [3043e98]
+- Updated dependencies [716ac9b]
+- Updated dependencies [7b3c033]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [14935ab]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [fd6bdf8]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [bc03179]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [ead96d0]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [c15eb23]
+- Updated dependencies [4d47afe]
+- Updated dependencies [2a9752c]
+- Updated dependencies [b740440]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [b6c7690]
+- Updated dependencies [3851f87]
+- Updated dependencies [845e164]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [8d017eb]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [4e3a4c3]
+- Updated dependencies [3b0b61c]
+- Updated dependencies [30d3752]
+- Updated dependencies [8914915]
+- Updated dependencies [a4c11ad]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [7fc01db]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [2416dd5]
+- Updated dependencies [88ef34d]
+- Updated dependencies [add2d19]
+- Updated dependencies [5d4d20e]
+- Updated dependencies [2b9d33a]
+- Updated dependencies [ad217b1]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [0f59584]
+- Updated dependencies [f6c904a]
+- Updated dependencies [ff08691]
+- Updated dependencies [159e299]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [52fbba6]
+- Updated dependencies [d5156b9]
+- Updated dependencies [75e66fc]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [a726154]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/metadata-protocol@17.1.0
+ - @objectstack/metadata@17.1.0
+ - @objectstack/metadata-core@17.1.0
+ - @objectstack/formula@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/objectql/package.json b/packages/objectql/package.json
index f81cd3b8ea..7d1eebb0eb 100644
--- a/packages/objectql/package.json
+++ b/packages/objectql/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/objectql",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Isomorphic ObjectQL Engine for ObjectStack",
"main": "dist/index.js",
diff --git a/packages/observability/CHANGELOG.md b/packages/observability/CHANGELOG.md
index ffc9b232f0..e3731dc323 100644
--- a/packages/observability/CHANGELOG.md
+++ b/packages/observability/CHANGELOG.md
@@ -1,5 +1,124 @@
# @objectstack/observability
+## 17.1.0
+
+### Patch Changes
+
+- 7ff3975: 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.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [19539b4]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+
## 17.0.0
### Patch Changes
diff --git a/packages/observability/package.json b/packages/observability/package.json
index f7e2a8de7f..319a791d9b 100644
--- a/packages/observability/package.json
+++ b/packages/observability/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/observability",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Observability contracts and exporters for ObjectStack — MetricsRegistry, ErrorReporter, Logger plus noop/console/OTLP-HTTP exporters. Deployment-target neutral; runtime and services depend on this so the same instrumentation works on Cloudflare Workers, Node, and self-hosted Kubernetes.",
"type": "module",
diff --git a/packages/platform-objects/CHANGELOG.md b/packages/platform-objects/CHANGELOG.md
index d8607f3864..b5017db67d 100644
--- a/packages/platform-objects/CHANGELOG.md
+++ b/packages/platform-objects/CHANGELOG.md
@@ -1,5 +1,615 @@
# @objectstack/platform-objects
+## 17.1.0
+
+### Minor Changes
+
+- e43d63a: feat(identity): API keys are minted against the minter's active organization, and carry it into the request (#8287)
+
+
+
+ On a deployment running `OS_TENANCY_POSTURE=isolated`, a minted API key could
+ read **nothing at all**. `sys_api_key` carried no organization column, so key
+ authentication established a user but no active organization — and the
+ `isolated` Layer 0 wall is `organization_id = activeOrganizationId`, which with
+ no active organization matches no row. Every organization-scoped read answered
+ `200` with `total 0` while the console went on offering minting, so a tenant
+ admin could mint a valid-looking secret and discover only at call time that it
+ read nothing. (There was no cross-tenant leak — the failure was in the other
+ direction.)
+
+ **The column was absent by an inherited rule, not by oversight.**
+ `resolveInjectedSystemColumns` injects `organization_id` into every registered
+ object *except* `managedBy: 'better-auth'` ones, and `sys_api_key` carries that
+ flag — even though better-auth's `apiKey` plugin is not loaded and the table is
+ hand-rolled ObjectStack. So the fix needs the declaration *and* the ADR-0105 D7
+ extension-field registration to stay consistent. The read side, by contrast,
+ was **already wired**: `resolveApiKeyPrincipal` already read an organization
+ into `tenantId` and `resolveAuthzContext` already adopted it — it was reading a
+ column no mint path ever wrote.
+
+ **What changes**
+
+ - `sys_api_key` declares `active_organization_id` (+ index, and the column is
+ shown in the "My Keys" and "All" list views, because the card's complaint was
+ a credential whose reach its owner could not see).
+ - `POST /api/v1/keys` **inherits** the caller's active organization — there is
+ deliberately no org parameter and no cross-org key — and **re-checks the
+ caller's `sys_member` membership at mint time**, honouring ADR-0091 validity
+ windows. Under a walled posture it refuses (400) rather than minting a key
+ with no organization, and refuses (403) for an organization the caller is not
+ a member of. The mint response echoes the organization the key is pinned to.
+ - The verifier reads **one spelling** (Prime Directive #12): the
+ `row.organization_id ?? row.organizationId` chain it used to carry was a
+ consumer-side tolerance for a producer that did not exist.
+ - An **ex-member's key fails closed at verify time** — no principal, not a
+ degrade to a user-only principal, which would resurrect the same
+ `200 + total 0` silent-empty. Checked at verify rather than by revoking on
+ membership loss, because membership ends through many paths (better-auth org
+ endpoints, SCIM, a direct `sys_member` delete, a lapsing validity window) and
+ a hook must catch every one or it silently misses. It costs **zero extra
+ queries**: the resolver has already read `sys_member` for this user.
+ - **Pre-existing org-less keys are never backfilled** — that would silently
+ upgrade credentials minted under a different promise. They keep working under
+ `single` (no wall) and under `group` (whose wall derives from the owner's
+ memberships independently of the active organization, so they already work
+ there), and are **refused under `isolated`**, where they are provably dead
+ today.
+
+ **The column is deliberately named `active_organization_id`, not
+ `organization_id`** — the `sys_session` spelling, for the same concept: the
+ organization a credential makes *active*. `objectHasOrgIdField` tests for the
+ literal `organization_id`, and Layer 0 exempts objects without it, so the other
+ name would have made `sys_api_key` itself org-walled. Both walled postures
+ exclude NULL, so every pre-existing org-less row would have vanished from its
+ **own owner's** "My Keys" list while, under `group`, continuing to
+ authenticate — a live credential nobody could see or revoke, which is a fresh
+ instance of the very class this change removes.
+- 6158146: Stop serving custom email headers through the generic data-API read of `sys_email` (#8149).
+
+ **What this closes.** `sys_email.headers_json` — the custom headers handed to `IEmailService.send`, the ordinary place a relay credential or provider token goes — was readable by every caller the data API admits (list, get, an explicit `?select=headers_json`). The column is now declared `internal: true`, so the engine omits it from every generic read with no system carve-out (#7728); `SYSTEM_CTX` does not reopen it either. This is the same shape #8118 ruled on for `sys_http_delivery.headers_json`: this change adopts that remedy rather than deciding it a second time.
+
+ **Delivery is unaffected, and fail-closed.** `sys_email` is not delivered from the in-memory message but FROM THE ROW: the after-insert outbox drain hook, the `email.send.async` queue subscriber and the boot outbox sweep all re-read the row and hand it to `EmailService.deliverPersistedRow`. All three read through `engine.find`, which is exactly what the flag empties — so the recovery ships with the flag. `deliverPersistedRow` now recovers the column through ObjectQL's privileged accessor (`resolveInternalField`, consumed unchanged) and sends every authored header verbatim. A message whose headers cannot be recovered is NOT sent without them: a missing header is not self-announcing — a relay that does not require it accepts the mail while the delivery silently deviates from the authored configuration. That case throws and leaves the row `queued`, not `failed`, so the queue retry or the next boot's sweep delivers it intact.
+
+ **New optional seam.** `EmailPersistence.readHeadersJson(rowIds)` — the readback the plugin wires off the raw engine. It probes the OBJECT SCHEMA flag, never the absence of the key from a result row: `headers_json` is `required: false` and most real rows carry no custom headers at all, so a key-absence inference would treat every ordinary email as redacted (the regression measured on `sys_account`'s optional token columns in #7987/PR #8675). Engines that do not redact are left untouched and trigger no privileged read.
+
+ **What this deliberately does NOT close.** The row still holds the header map in cleartext at rest. Encrypting it (`Field.secret()`) was measured and rejected on #8118 — an orphan `sys_secret` row per message with no cascade or retention, a boot-window fail-open, and a per-row decrypt on every delivery — and this change adopts that ruling unchanged.
+
+### Patch Changes
+
+- c9f5950: fix(security): `sys_account`'s OAuth access/refresh/id tokens stop serializing on the data API — `internal: true`, with better-auth's readback seam widened to cover them (#7987)
+
+
+
+ `sys_account.access_token`, `.refresh_token` and `.id_token` hold each user's
+ **live third-party OAuth credentials** — the tokens ObjectStack received from
+ Google, GitHub or an OIDC IdP — in cleartext (better-auth's
+ `account.encryptOAuthTokens` is not set, so `setTokenUtil` stores them
+ verbatim). They were plain `Field.textarea` on an object declaring
+ `apiEnabled: true, apiMethods: ['get','list']`.
+
+ **Both personas were measured leaking, on a real booted stack** (`bootStack(showcaseStack)`,
+ in-process HTTP + sqlite-wasm), with a planted token on a member's account row:
+
+ - **admin**, `GET /data/sys_account/{another user's account id}` — 200, that
+ member's `refresh_token` verbatim, plus `access_token` and `id_token`;
+ - **member**, `GET /data/sys_account` (self-scoped by the `sys_account_self` RLS
+ policy) — 200, their **own** `refresh_token` verbatim.
+
+ The member arm is the one this object does not share with its `sys_session`
+ sibling (#7823), and it is the sharper of the two: it converts a short-lived,
+ revocable ObjectStack session bearer into a **long-lived third-party refresh
+ token that this platform cannot revoke at all**. Neither collector reached these
+ columns — the engine's credential mask collects by field TYPE (`textarea` is
+ neither `secret` nor `password`) *and* exempts objects with
+ `managedBy: 'better-auth'`, which this object is.
+
+ **The fix is three declarations plus one widening**, inheriting #7823's shape
+ rather than inventing a second mechanism:
+
+ - the three columns are declared `internal: true` — the opt-in, type-independent
+ flag minted by #7728 meaning *the declared value is never returned on the
+ generic data path*. Storage, filtering and indexing are untouched: the strip
+ runs on rows the driver has already produced.
+ - better-auth **reads these back off adapter result rows** — measured, and the
+ risk this card was parked on: `internalAdapter.findAccounts(userId)` issues a
+ `findMany` with no projection, and `/get-access-token`, `/account-info` and
+ `/refresh-token` then read `account.refreshToken` / `.accessToken` /
+ `.idToken` off those rows. The read strip alone would answer
+ `REFRESH_TOKEN_NOT_FOUND` (400) and hand back an empty access token. So the
+ existing readback seam in `@objectstack/plugin-auth` — which already recovered
+ `sys_session.token` through `Engine.resolveInternalField` (#8118's privileged
+ batch accessor) — is widened to cover these three columns and renamed
+ accordingly. No engine carve-out, no second accessor.
+
+ **Not retyped, deliberately.** `Field.secret()` would route better-auth's own
+ writes through the engine's encrypt-on-write path, placing the engine between
+ better-auth and its own adapter. `Field.password()` is inert here for the two
+ reasons above.
+
+ **`password` / `previous_password_hashes` are deliberately out of scope** —
+ they are better-auth one-way hashes (ADR-0100's third channel), not reversible
+ outbound credentials, and the readback seam refuses to touch them.
+
+ The regression proof drives both directions: the fixture PLANTS real token
+ values and re-reads them out of storage through the privileged accessor before
+ asserting anything (so "absent from the response" cannot pass vacuously), then
+ pins that the values are still on disk, still usable as a server-side predicate,
+ and that password sign-in — which reads a `sys_account` row back through the
+ same seam on every request — still works.
+- d6e80b2: fix(security): `sys_account.password` and `previous_password_hashes` stop serializing on the data API — `internal: true`, with the raw-engine readers converted to the privileged accessor (#8676)
+
+
+
+ `sys_account.password` (the credential hash) and `previous_password_hashes` (the
+ ADR-0069 D1 reuse-prevention ring) serialized on `/api/v1/data/sys_account`,
+ which declares `apiEnabled: true, apiMethods: ['get','list']` — to an **admin
+ for every user's row**, and to a **member for their own** (the
+ `sys_account_self` RLS policy grants `select` on `user_id == current_user.id`).
+
+ These are one-way hashes, not reversible outbound credentials — which is why
+ #7987 correctly refused to bundle them with the OAuth tokens. But a served
+ password hash is an offline-cracking target, and `previous_password_hashes`
+ multiplies it by the history ring while its own declaration says it is *never
+ exposed in UI*. This is the disposition #7728 already reached for
+ `sys_api_key.key`, which was **also** a stored hash and was still ruled unfit to
+ serialize through the API face.
+
+ Neither credential collector could reach them: `collectMaskedReadFields` keys on
+ the field **TYPE** (`secret` / `password`) *and* exempts objects declaring
+ `managedBy: 'better-auth'`, which this object is — while these columns are
+ `text` / `textarea`. Two independent barriers, both missing.
+
+ **The fix is two declarations plus two recovery seams**, and the second seam is
+ the part a bare flag would have missed:
+
+ - both columns are declared `internal: true` — the opt-in, type-independent flag
+ from #7728 meaning *the declared value is never returned on the generic data
+ path*. Storage, filtering and indexing are untouched: the strip runs on rows
+ the driver has already produced.
+ - **better-auth's adapter readers** are recovered by the existing per-object
+ readback table, widened with `password`: the sign-in verifier compares against
+ the hash on the row `internalAdapter.findCredentialAccount(userId)` returns,
+ so the strip alone would break password sign-in for every user.
+ - **plugin-auth's own RAW-engine readers** are recovered by a new seam in the
+ same module, `recoverInternalFieldsForSystemRead`. This is the half that makes
+ the flag safe: the readback table is imported by exactly one file
+ (better-auth's storage adapter), so it cannot reach a caller that reads the
+ engine directly — and the engine's strip has **no `isSystem` carve-out** by
+ #7728's design. Measured against a real ObjectQL engine: the reuse ring's
+ `findOne` returns `{"id":"a1"}` for a query that names both columns in an
+ explicit projection under `context: { isSystem: true }`.
+
+ Left unrecovered, `assertPasswordNotReused` would become a **silent no-op** —
+ its comparison list empties, the loop never runs, `PASSWORD_REUSE` is never
+ thrown, and its own `catch { return undefined }` means nothing announces it.
+ The ADR-0069 D1 control would report success while accepting every reused
+ password. Its unit tests would have stayed green throughout, because they use
+ fake engines that never apply the strip.
+
+ **No ADR-0100 guard change, and none was needed.** `Engine.resolveInternalField`
+ has exactly one predicate — `internal === true` — so flagging the columns makes
+ them legitimately dereferenceable through the privileged accessor. The ADR-0100
+ sentence in its refusal message is prose explaining why a *non-flagged* field has
+ other channels, not a second predicate; the guard stays exactly as selective as
+ it was, and a non-flagged column on the same object is still refused with
+ `INVALID_FIELD` / 400.
+
+ Regression proof drives both directions on a real booted stack: both columns are
+ absent for both personas — including a caller who spells them out in `?select=` —
+ while the values remain on disk and reachable through the privileged accessor,
+ password sign-in still works, and the reuse ring still grows across a password
+ change on every transport lane.
+- 66beee0: 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.
+- 03520eb: 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.
+- 04f8fdb: fix(platform-objects): drop the dead `mapId` ("Map: User ID claim") param from `register_sso_provider` — the OIDC subject claim is not configurable (#8222)
+
+
+
+ The `register_sso_provider` action on `sys_sso_provider` offered an optional
+ **"Map: User ID claim"** text field (`mapId`), with helpText reading *"Optional.
+ ID-token claim mapped to the user ID. Defaults to `sub`."*
+
+ **That capability no longer exists.** It was retired upstream in
+ `@better-auth/sso@1.7.0-rc.2`:
+
+ - `oidcConfig.mapping` is a `z.strictObject` whose members are
+ `{ email, emailVerified?, name, image?, extraFields? }` — there is no `id`;
+ - the federated subject is hard-wired to the OIDC `sub` claim
+ (`id: readStringClaim(rawUserInfo, "sub")` and `id: idToken.sub`), then
+ cross-checked (`id_token_subject_missing`,
+ `id_token_userinfo_subject_mismatch`);
+ - `extraFields` is not an escape hatch — it is spread **before** `id` in the
+ profile literal, so an `extraFields.id` is overwritten by `sub` before anything
+ reads it.
+
+ `1.6.20` did honour `mapping.id` (`id: rawUserInfo[mapping.id || "sub"]`); the
+ version bump deleted the member.
+
+ So the field's only accepted values were "empty" and the `sub` it already
+ defaulted to. #8193 (PR #8221) stopped the bridge emitting the retired key and —
+ rather than accept a value it would silently discard — made a non-`sub` value
+ answer `INVALID_REQUEST`. That left the last half of the problem: **the form
+ still advertised a free-form optional field that 400s on anything meaningful.**
+ Removing it restores declared = enforced. Nothing else about registration moves:
+ the runtime accept set is unchanged, and a registration that never sent `mapId`
+ behaves exactly as before.
+
+ `mapEmail` and `mapName` are untouched — they map to live `oidcMappingSchema`
+ members and are still honoured.
+
+ **The bridge-side guard in `plugin-auth`'s `register-sso-provider.ts` is kept**,
+ and its refusal test with it. The admin form was only one caller: a direct API
+ client, a script, or a stale cached console bundle can still put `mapId` on the
+ wire, and telling those callers plainly still beats discarding the value in
+ silence. Only the guard's doc comment changed, to stop describing `mapId` as a
+ field the form sends.
+
+ The generated translation bundles (`*.objects.generated.ts`, all four locales)
+ were **regenerated**, not hand-edited, so the retired label disappears from every
+ locale rather than lingering as a stale entry.
+- 84cb121: State `sys_job`'s uniqueness boundary explicitly: `unique: 'global'` on the declared `(name)` index, and correct the `name` field's description (#8578)
+
+ The declared index carried the bare `unique: true` spelling, which ADR-0120 D1 defines as the deprecated positional spelling of `'global'` — the listed columns verbatim. Because `sys_job` also carries a kernel-injected `organization_id`, the tenancy sweep could not tell that shape apart from the #8323 cross-tenant-oracle class, and the field's description published a boundary-free "Unique job identifier" claim that left the question open in the generated reference.
+
+ The reading settles it in the `'global'` direction: nothing writes `sys_job` per organization. `DbJobAdapter` is the sole writer and upserts under a SYSTEM context, locating rows by `where: { name }` with no organization dimension; the `job` metadata type is closed to tenants on all three flags (`allowOrgOverride: false` — "no per-org job fork" — plus `allowRuntimeCreate: false` and `supportsOverlay: false`); `enable.apiMethods` advertises no write verb at all (ADR-0103 engine-owned); and every `schedule()` call site is registration-time and installation-scoped. ADR-0120's own S5 inventory already names `sys_job.name` as one of the nine engine idempotency keys that are platform-wide by construction.
+
+ No migration and no drift: `'global'` **is** the semantics bare `true` already materialized, so the physical index is byte-identical (ADR-0120 D2). What changes is that the boundary is stated rather than inferred from position, and that the published description names it. The reading itself is pinned — the new test asserts the write paths that would have to open for the opposite verdict to become true, so a future per-organization job path fails loudly instead of silently invalidating the constraint.
+- ca19ee8: Fix `sys_job_run`'s object `description` to say "history", not "audit trail" (#9735)
+
+ `sys_job_run` is job run **history**; `sys_audit_log` is the separate audit surface,
+ with its own opt-in, writer and retention (binding ruling on #9633). The object's own
+ header comment already said "Background Job Execution History", but the `description`
+ field two lines below — the user-facing copy Studio/Setup surface, and the string that
+ propagates into the generated translation bundle — still called it "Background job
+ execution audit trail". Both now say "Background job execution history"; the generated
+ `en.objects.generated.ts` bundle was regenerated to match (never hand-edited).
+- a675b4d: fix(platform-objects): the System Overview by-action table serves its declared title again, and the default locale bundle is now pinned to the source string (#8721)
+
+ `widget_recent_events` was converted into an ADR-0021 single-form — a
+ dataset-bound breakdown of `sys_audit_log` events by action — but all four
+ hand-authored locale bundles kept serving the title the widget had *before* the
+ conversion (`Recent Audit Events` / `最近审计事件` / `最近の監査イベント` /
+ `Eventos de Auditoría Recientes`). The translation is what renders, so the
+ declared string reached nobody in any locale. Its `description` had drifted the
+ same way and in the same direction, one field over.
+
+ **The duplicate the stale translation was hiding.** With the source string
+ restored, the board carried the same label twice: `widget_events_by_type` (a
+ pie) and `widget_recent_events` (a table) both declared `Audit Events by
+ Action`, over the same dataset and the same dimension. They looked distinct in a
+ running instance only because one of them was serving a stale translation. The
+ pair now splits on what each adds — the pie keeps `Audit Events by Action` (the
+ share picture), the table becomes **`Event Volume by Action`** (the exact
+ per-action count, which is what its `values: ['event_count']` produces and what
+ its description already said). All four locales are translated to the new
+ strings; the widget **ids are unchanged**, so no translation key, persisted
+ widget state or dataset binding moves.
+
+ **Why nothing caught it, and what now does.** This package's `apps` /
+ `dashboards` / `pages` i18n is hand-authored and cannot be regenerated —
+ regenerating would delete ~40 runtime-contributed nav translations per locale —
+ so it never had the source-tracking the generated half gets from the extractor.
+ Every gate over it made a **key-set** claim (`app-nav-translation-parity.test.ts`
+ asserts a translation exists and does not outlive its declaration;
+ `check:i18n-coverage` ratchets *untranslated* labels; `check:app-nav-i18n` judges
+ the merged nav tree), and a key whose value is stale satisfies all of them.
+
+ `app-nav-translation-parity.test.ts` now also asserts the **default locale's
+ content**: every statically declared app label, description and nav label, plus
+ the dashboard's label, description and every widget title/description, must
+ appear in `en.ts` **verbatim**. That claim is available for `en` alone because
+ `en` is a copy of the source rather than a translation of it — the same
+ invariant the generated half already enforces by rewriting its `en` bundle on
+ every extract. What a *translated* locale should do when its source string
+ changes is a separate product decision and is deliberately not decided here.
+- b887013: fix(platform-objects): remove the System Overview board's permanently-empty "Permission Changes" tile (#8148, #7675)
+
+
+
+ The System Overview dashboard shipped a "Permission Changes" metric tile
+ filtering `sys_audit_log.action = 'permission_change'`. **The tile could never
+ report anything but `0`, on any deployment that has ever existed** — the value
+ had no writer anywhere in the repo. There are exactly two `sys_audit_log`
+ writers: `plugin-audit`'s generic hook writer, whose `actionFor` maps
+ afterInsert/afterUpdate/afterDelete to `create`/`update`/`delete` and nothing
+ else, and `plugin-auth`'s admin user-import. Neither has ever emitted
+ `permission_change`. #8147 then retired the value from the action enum outright,
+ so the tile's filter now names a value the platform does not even declare.
+
+ **An empty tile on a compliance surface is worse than a missing one.** A
+ permanently-`0` "Permission Changes" count does not read as "this platform does
+ not track permission changes" — it reads as a *negative finding*: an auditor
+ concludes the platform watched for permission changes over the selected window
+ and found none. The number was live and the query was real; the question it
+ answered was one no row could ever be an answer to. 审计面宁窄勿谎 — a narrow
+ audit surface beats a lying one.
+
+ **Removed rather than refiltered onto a live action.** Permission and role edits
+ *are* captured today, as ordinary `create` / `update` rows written by the generic
+ hook against the permission objects — so the honest lens on them is `object_name`
+ on the audit list view, a row-level question rather than a single-number KPI.
+ Approximating one as a tile would have put a second not-quite-true number on the
+ same board. The two surviving Row 2 tiles ("Login Events", "Config Changes")
+ split the 12-column row in half instead of leaving a gap where the removed tile
+ sat.
+
+ The by-action tile's description stops naming `permission` among its example
+ actions, in the source **and in all four locale bundles** — the translations are
+ the strings actually served, so correcting only the source would not have reached
+ a single user.
+
+ ⚠️ **`import` is deliberately untouched.** It was named in the same ruling as
+ `permission_change`, but its retirement premise was falsified during #8147: it
+ has a live writer (`plugin-auth`'s admin user-import writes a run-level row) and
+ a shipped list view that filters it. Removing it from the dashboard while the
+ platform still emits it would produce the exact inverse defect — an audit action
+ that can be written but cannot be found.
+
+ Both directions are pinned. A tombstone refuses any board widget filtering a
+ retired action value, with a live-action control so it cannot pass on a board
+ that has no widgets or whose predicates moved. The app/dashboard translation
+ parity test gains the **reverse direction it was missing** for dashboard widgets
+ — it asserted every declared widget has a translation, but nothing stopped a
+ translation outliving its widget, which is precisely what these four locale
+ entries would have done.
+- 7901b2d: feat(spec): stamp-only `tenancy.organizationField` — audit rows can follow the record's organization on objects that must stay unwalled (#8778, closes the #8707 remainder)
+
+ The platform had one answer to "what is this object WALLED by"
+ (`tenancy.tenantField`) and no answer to "which column says who this row is
+ ABOUT". For ordinary objects the two coincide; for credential tables they
+ deliberately do not — `sys_api_key` records the organization a key
+ authenticates into under `active_organization_id` precisely so the credential
+ table is not org-walled (#8287). #8777's schema-resolved audit stamping could
+ therefore reach every shipped object except the one that motivated it, and
+ revocation rows on `sys_api_key` kept stamping the revoker's organization.
+
+ `TenancyConfigSchema` now accepts an optional `organizationField` — a
+ READ-NEUTRAL, STAMP-ONLY declaration (maintainer-ruled option A on #8778):
+
+ - The audit writer's `resolveRecordOrganizationField` consults it first, ahead
+ of the ADR-0066 `enabled: false` opt-out — an author declaring it on an
+ unwalled object is stating exactly that the audit trail should follow the
+ record's own organization even though no wall does. It is honoured only when
+ the object really has the field (the #5315 guard `tenantField` carries).
+ - No read path reads it: `applyTenantScope`, `injectTenantOnInsert`,
+ `computeTenantLayer0Filter` and `resolveInjectedSystemColumns` are all
+ measured blind to it, and that read-neutrality is pinned by tests beside
+ each. Declaring it never walls an object and never hides rows.
+ - ⛔ Scope pin from the ruling: this is ONE stamp-only key, not the opening
+ move of a general field-roles mechanism. A consumer other than audit
+ stamping needs its own ruling before reading it.
+
+ `sys_api_key` now declares
+ `tenancy: { enabled: false, organizationField: 'active_organization_id' }`,
+ so revoking another user's key from a different active organization lands the
+ audit row behind the wall of the KEY's organization — where the tenant admin
+ who can act on it reads it. The `enabled: false` is measured
+ behavior-identical to the previous absent block for this object on every read
+ path (injection bails on `managedBy: 'better-auth'` first; the SQL driver's
+ tenant field resolves null either way; Layer 0 is exempt either way; the
+ memory/mongo boot guards count only an explicit `enabled: true`).
+- b3f9831: fix(platform-objects): a translated Setup/Studio/Account label whose source string has been edited underneath it now serves the source text instead of the stale translation (#8765)
+
+ The `apps` / `dashboards` / `pages` half of this package's i18n is hand-authored
+ per locale. Every gate over it judges **presence or ownership** —
+ `app-nav-translation-parity.test.ts` (a translation exists for every declared
+ id, and none outlives its declaration), `check:i18n-coverage` (ratchets
+ *untranslated* labels), `check:app-nav-i18n` (a label per locale on the merged
+ nav tree). A translated value that has gone **stale** satisfies every one of
+ them: it is present, it is owned, it is not untranslated.
+
+ So a source-string edit left `zh-CN` / `ja-JP` / `es-ES` serving the previous
+ translation indefinitely, under a fully green build — which is how
+ `widget_recent_events` shipped its pre-conversion title in all four locales.
+ Pinning `en` to the declared source did not create that drift, but it removed
+ the one accidental symptom that made it visible: the drift stopped being
+ uniform across four bundles and became locale-specific, invisible to every
+ reviewer who reads the product in English.
+
+ **Ruled Option B** (#8765): record the source hash at translation time; a hash
+ mismatch marks the translation stale, and stale falls back to the source text.
+
+ - Each translated locale ships a `.source-hashes.ts` table recording,
+ per leaf, the digest of the `en` source string that leaf was translated from.
+ `setup.translation.ts` compares them against the current source when it
+ assembles the bundle the kernel is handed.
+ - **Edit a source string** ⇒ that leaf falls back to the source text in every
+ locale that had translated it.
+ - **Update one translation** (its value *and* its recorded hash) ⇒ **that locale
+ alone recovers**; the others keep falling back.
+ - **A leaf with no recorded hash is legacy-trusted**, not stale. The tables were
+ backfilled once from the then-current source, so no existing translation
+ degraded when this landed.
+
+ **No new failure mode, and no new gate.** The fallback substitutes the source
+ string rather than deleting the key, so no key set moves; a translated locale
+ carrying the source string verbatim is exactly what the extractor already
+ writes for an untranslated key under `--fill=default`, and exactly what the
+ resolver's locale chain has always rendered. Staleness degrades what is
+ *served* — it never fails a build, which would put a four-locale translation
+ task in front of every one-word source edit.
+
+ Scope is the hand-authored sections only. `objects` / `metadataForms` are
+ generated, and the hole cannot occur there: `os i18n extract` rewrites the `en`
+ bundle from the source on every run and does not merge the default locale, so a
+ source edit either lands in the generated bundle or fails `check:i18n` as drift.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [19539b4]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [b6c7690]
+- Updated dependencies [3851f87]
+- Updated dependencies [845e164]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [7fc01db]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/metadata-core@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json
index f81ca5ba69..a7ac27bd0d 100644
--- a/packages/platform-objects/package.json
+++ b/packages/platform-objects/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/platform-objects",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Core platform object schemas for ObjectStack — identity, security, audit, tenant, and metadata objects",
"main": "dist/index.js",
diff --git a/packages/plugins/embedder-openai/CHANGELOG.md b/packages/plugins/embedder-openai/CHANGELOG.md
index 4289455b83..c9d1ce95d3 100644
--- a/packages/plugins/embedder-openai/CHANGELOG.md
+++ b/packages/plugins/embedder-openai/CHANGELOG.md
@@ -1,5 +1,88 @@
# @objectstack/embedder-openai
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [19539b4]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+
## 17.0.0
### Patch Changes
diff --git a/packages/plugins/embedder-openai/package.json b/packages/plugins/embedder-openai/package.json
index 1503bfd16c..81171f638d 100644
--- a/packages/plugins/embedder-openai/package.json
+++ b/packages/plugins/embedder-openai/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/embedder-openai",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "OpenAI-compatible embedder for ObjectStack — works against OpenAI, 阿里通义 DashScope, 智谱 BigModel, 硅基流动 SiliconFlow, 火山引擎 Doubao, MiniMax, Ollama, and any drop-in OpenAI-shape endpoint.",
"main": "dist/index.js",
diff --git a/packages/plugins/knowledge-memory/CHANGELOG.md b/packages/plugins/knowledge-memory/CHANGELOG.md
index ca4c4d2456..3657c40f35 100644
--- a/packages/plugins/knowledge-memory/CHANGELOG.md
+++ b/packages/plugins/knowledge-memory/CHANGELOG.md
@@ -1,5 +1,98 @@
# @objectstack/knowledge-memory
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [0425db9]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/service-knowledge@17.1.0
+
## 17.0.0
### Patch Changes
diff --git a/packages/plugins/knowledge-memory/package.json b/packages/plugins/knowledge-memory/package.json
index 3bbf134f3c..f26f4add1f 100644
--- a/packages/plugins/knowledge-memory/package.json
+++ b/packages/plugins/knowledge-memory/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/knowledge-memory",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "In-memory knowledge adapter for ObjectStack (dev / test reference implementation).",
"main": "dist/index.js",
diff --git a/packages/plugins/knowledge-ragflow/CHANGELOG.md b/packages/plugins/knowledge-ragflow/CHANGELOG.md
index 9873a85cad..bbc4d5f5c9 100644
--- a/packages/plugins/knowledge-ragflow/CHANGELOG.md
+++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md
@@ -1,5 +1,128 @@
# @objectstack/knowledge-ragflow
+## 17.1.0
+
+### Patch Changes
+
+- 0425db9: Published READMEs link to the docs site in the one form that works on npm, on GitHub and on the docs site (#9632)
+
+ **Seven docs links in these READMEs pointed nowhere.** They were spelled as a repo
+ path rooted at `/` — `[Flows](/content/docs/automation/flows.mdx)` — and a README in a
+ package's `files` array with `private` unset is rendered on the **npm package page** and
+ on **GitHub**, not only in this repository. There a root-relative href resolves against
+ `npmjs.com` and `github.com` respectively. It was not a docs-site route either:
+ `apps/docs/lib/source.ts` mounts `loader({ baseUrl: '/docs' })` over `content/docs`, so
+ the route for that first link is `/docs/automation/flows`, and `apps/docs/redirects.mjs`
+ carries no `/content` source that would rescue the written form. Every target page
+ existed and every one of them was reachable — only the links were not.
+
+ All seven now use the absolute form the repo had already established in
+ `create-objectstack`'s published READMEs: `https://docs.objectstack.ai/docs/...`, with
+ the path taken under `content/docs` and the page extension dropped, because the route
+ carries none. Each target was re-verified at the route level rather than as a file — the
+ two that named a **directory** (`/content/docs/automation/`,
+ `/content/docs/references/automation/`) resolve only because those directories carry an
+ `index.mdx`; a directory without one is a 404, not a section.
+
+ **Two more links in the same class were converted in the same pass.**
+ `service-knowledge` and `knowledge-ragflow` pointed at
+ `../../../content/docs/protocol/knowledge.mdx`. Those relative paths do resolve on both
+ GitHub and npm, so they are a milder defect than the seven — but they land the reader on
+ **raw MDX source** instead of the rendered page. They now point at the rendered page as
+ well. `service-knowledge`'s link text changed with it: it was the source filename in a
+ code span, which stops being an honest label once the destination is the page.
+
+ No API, behaviour or type surface changes — this is the published documentation these
+ packages ship.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [0425db9]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/service-knowledge@17.1.0
+
## 17.0.0
### Patch Changes
diff --git a/packages/plugins/knowledge-ragflow/package.json b/packages/plugins/knowledge-ragflow/package.json
index f7eca57dba..06f24194e2 100644
--- a/packages/plugins/knowledge-ragflow/package.json
+++ b/packages/plugins/knowledge-ragflow/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/knowledge-ragflow",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "RAGFlow knowledge adapter for ObjectStack — production-grade RAG via the Apache 2.0 RAGFlow REST API.",
"main": "dist/index.js",
diff --git a/packages/plugins/plugin-approvals/CHANGELOG.md b/packages/plugins/plugin-approvals/CHANGELOG.md
index 5c94ec5915..9cac6f4e85 100644
--- a/packages/plugins/plugin-approvals/CHANGELOG.md
+++ b/packages/plugins/plugin-approvals/CHANGELOG.md
@@ -1,5 +1,189 @@
# @objectstack/plugin-approvals
+## 17.1.0
+
+### Minor Changes
+
+- 08d6d3c: feat(approvals): read-only approval visibility for users who can read the target record, per object, default OFF (#8652)
+
+ A new `ApprovalsPluginOptions.recordReaderVisibleObjects` names the objects on
+ which **a user who can READ a business record may also see that record's
+ approval requests and full action history** — read-only. Omitted or empty (the
+ default) leaves visibility exactly as it is today, so an existing deployment
+ sees no behaviour change on upgrade. **This is not a no-op change**: on an
+ object you list, a population that could previously see nothing gains a real
+ read.
+
+ ```ts
+ new ApprovalsServicePlugin({ recordReaderVisibleObjects: ['exam_sheet'] })
+ ```
+
+ **Who gains visibility.** Until now the visible set was submitter ∪ current
+ approver ∪ historical actor, with a platform/tenant admin override as the only
+ bypass — so a ledger or supervisor role that holds full read on the record but
+ never appears in the approval itself received `200` with an empty list, and the
+ Console's approval tab never rendered. On an enabled object, that role now sees
+ the record's approvals.
+
+ **What becomes visible on an enabled object**, stated plainly because the switch
+ is an opt-in decision about confidentiality:
+
+ - the approval request row, including its `payload` snapshot of the record as it
+ stood at submission time;
+ - the full action history — each actor, their decision, the timestamp, **and the
+ action's comment text** (意见正文);
+ - decision attachments on those actions, which are gated on the same rule.
+
+ Enable it on objects whose approval commentary the record's readers are meant to
+ see; the comment text is often evaluative, and it is per object precisely so
+ that enabling it for a ledger object does not enable it for anything else.
+
+ **What does NOT change.**
+
+ - **Read-only.** No approval action is delivered through this tier. Approve,
+ reject, reassign, recall and comment keep authorizing exactly as before — on
+ the pending-approver slate, the submitter, or admin override — and a viewer
+ admitted by this tier gets `can_act: false`. Seeing a request confers nothing.
+ - **No new permission concept.** The tier is anchored on the existing
+ record-read permission: the service asks the engine to read the record **as
+ the caller**, so ordinary object CRUD and RLS decide. No new role, grant type
+ or policy, and no host-injected visibility hook — a security predicate the
+ platform can neither constrain nor audit was considered and rejected.
+ - **The inbox.** An untargeted list is unchanged. The rule is anchored on one
+ record, so it applies only where a record is named — a list filtered by
+ `object` + `recordId` (what a record page's approval tab sends), or a request
+ loaded by id. A work queue does not become a browse surface.
+ - **Tenant isolation, and everything else about the existing visible set.** The
+ tier only ever adds ids to the participant set; it can never return the "sees
+ everything" verdict and never relaxes an existing constraint.
+
+### Patch Changes
+
+- 66beee0: 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.
+- 7ff5aa2: `sys_automation_run` resolves its organization from the DECLARED `AutomationContext.tenantId` and from no other spelling (cloud#1395).
+
+ The suspended-run store read `context.organizationId ?? context.tenantId`. `AutomationContext` declares `tenantId` and not `organizationId`, and no producer writes the latter — `RecordChangeTrigger.buildContext` maps the hook session's organization onto `tenantId`, and the runtime's automation domain sets `tenantId` directly. The dead limb was not inert: the one test covering `sys_automation_run.organization_id` fed the phantom key, so the column's only coverage exercised a path production cannot reach and said nothing about the live one. The limb is removed, the fixture speaks the declared contract, and a test now asserts the absence so restoring the alias goes red.
+
+ Both `sys_approval_request.organization_id` and `sys_automation_run.organization_id` now document the measured attribution defect this uncovered and the negative control that makes it a defect: on a walled single-database boot these two tables stored customer activity with no organization (27/27 and 31/31) while `sys_audit_log` (1669 rows) was correctly attributed on the same boot, because the audit writer resolves the organization from the record the row is ABOUT rather than from the acting context. The write-side repair is not in this change — which column a side-table row should follow is an open contract question, since the audit resolver is scope-pinned to audit stamping by the #8778 ruling. The current behaviour is pinned by test so the fix must promote the assertion rather than quietly satisfy it.
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2d0af57]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [b6c7690]
+- Updated dependencies [3851f87]
+- Updated dependencies [845e164]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [7fc01db]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/metadata-core@17.1.0
+ - @objectstack/formula@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json
index 3f8667db37..084108f735 100644
--- a/packages/plugins/plugin-approvals/package.json
+++ b/packages/plugins/plugin-approvals/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/plugin-approvals",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Multi-step approval engine for ObjectStack — sys_approval_process + sys_approval_request + sys_approval_action + IApprovalService.",
"main": "dist/index.js",
diff --git a/packages/plugins/plugin-audit/CHANGELOG.md b/packages/plugins/plugin-audit/CHANGELOG.md
index 9de69f44c0..a99bd020b4 100644
--- a/packages/plugins/plugin-audit/CHANGELOG.md
+++ b/packages/plugins/plugin-audit/CHANGELOG.md
@@ -1,5 +1,482 @@
# @objectstack/plugin-audit
+## 17.1.0
+
+### Minor Changes
+
+- 7901b2d: feat(spec): stamp-only `tenancy.organizationField` — audit rows can follow the record's organization on objects that must stay unwalled (#8778, closes the #8707 remainder)
+
+ The platform had one answer to "what is this object WALLED by"
+ (`tenancy.tenantField`) and no answer to "which column says who this row is
+ ABOUT". For ordinary objects the two coincide; for credential tables they
+ deliberately do not — `sys_api_key` records the organization a key
+ authenticates into under `active_organization_id` precisely so the credential
+ table is not org-walled (#8287). #8777's schema-resolved audit stamping could
+ therefore reach every shipped object except the one that motivated it, and
+ revocation rows on `sys_api_key` kept stamping the revoker's organization.
+
+ `TenancyConfigSchema` now accepts an optional `organizationField` — a
+ READ-NEUTRAL, STAMP-ONLY declaration (maintainer-ruled option A on #8778):
+
+ - The audit writer's `resolveRecordOrganizationField` consults it first, ahead
+ of the ADR-0066 `enabled: false` opt-out — an author declaring it on an
+ unwalled object is stating exactly that the audit trail should follow the
+ record's own organization even though no wall does. It is honoured only when
+ the object really has the field (the #5315 guard `tenantField` carries).
+ - No read path reads it: `applyTenantScope`, `injectTenantOnInsert`,
+ `computeTenantLayer0Filter` and `resolveInjectedSystemColumns` are all
+ measured blind to it, and that read-neutrality is pinned by tests beside
+ each. Declaring it never walls an object and never hides rows.
+ - ⛔ Scope pin from the ruling: this is ONE stamp-only key, not the opening
+ move of a general field-roles mechanism. A consumer other than audit
+ stamping needs its own ruling before reading it.
+
+ `sys_api_key` now declares
+ `tenancy: { enabled: false, organizationField: 'active_organization_id' }`,
+ so revoking another user's key from a different active organization lands the
+ audit row behind the wall of the KEY's organization — where the tenant admin
+ who can act on it reads it. The `enabled: false` is measured
+ behavior-identical to the previous absent block for this object on every read
+ path (injection bails on `managedBy: 'better-auth'` first; the SQL driver's
+ tenant field resolves null either way; Layer 0 is exempt either way; the
+ memory/mongo boot guards count only an explicit `enabled: true`).
+- 5126e79: Record-view auditing: `sys_audit_log` can now answer "who viewed which record"
+
+ `sys_audit_log` covered writes only, so the question every regulated-industry
+ security review opens with — *who viewed this customer record, and when?* — had
+ no answer short of custom work. The ledger now has a `read` action, its writer,
+ and the `record_views` list view that surfaces it.
+
+ Scope is deliberately narrow (maintainer ruling 2026-08-16):
+
+ - **Record-detail views only.** A read qualifies when it materialized one record
+ and its predicate pinned the primary key — the shape `GET /data/:object/:id`
+ produces. List and search reads are not audited.
+ - **Per-object opt-in, closed.** Nothing is recorded until a deployment names the
+ objects: `new AuditPlugin({ readAudit: { objects: ['contact', 'account'] } })`.
+ There is no global switch and no exception list, and an empty opt-in registers
+ no hook at all, so the default posture costs a read nothing.
+ - **Batched off the request path.** The hook buffers and returns; rows are
+ persisted on a later tick, size- or timer-triggered, and flushed on shutdown.
+ Each row keeps the instant the record was VIEWED, not the instant its batch
+ drained.
+
+ The row records who, what and when — never field values. Read auditing runs
+ inside the security middleware, ahead of its field masking, so the record it sees
+ is pre-mask; copying values in would mint a plaintext copy of exactly what
+ field-level security withholds, in the table compliance staff are granted broad
+ access to.
+
+ Two boundaries are declared rather than left to be discovered: a system-elevated
+ read (`api.sudo()`, formula recomputes, roll-ups) writes no row, and neither does
+ a read with no principal to name.
+
+### Patch Changes
+
+- 1408fe3: fix(audit): audit rows are stamped from the record's own organization, not the actor's active one (#8707)
+
+ `sys_audit_log` / `sys_activity` rows took their organization from
+ `sess.tenantId ?? recordOrgId` — the ACTING session's active organization in
+ preference to the organization of the record the row is about. A write
+ performed from a session whose active organization differs from the record's
+ therefore landed the audit row behind the wrong tenant's wall: unreadable to
+ the tenant admin it concerns, and readable by an organization with no claim to
+ the record. That is the invisible-audit-row defect the record-side fallback was
+ added to prevent, one layer down, and the maintainer's ruling on #8287 settles
+ it the other way — the stamp comes from the row's own organization.
+
+ The precedence is now `recordOrgId ?? sess.tenantId`. The RLS fallback is
+ preserved unchanged: an audit row must never be written with a NULL
+ organization, so the acting session's tenant still answers whenever the record
+ has no organization of its own (single-tenant stacks, platform-global objects,
+ a NULL column), and the record's organization still answers on the two cases
+ the fallback was written for — background/sudo paths with no `tenantId`, and
+ better-auth's `activeOrganizationId` cache miss right after sign-in.
+
+ Which column carries a record's organization is now resolved from the
+ REGISTERED SCHEMA rather than the hard-coded `organization_id` literal, with
+ the same precedence `SqlDriver.computeTenantField` already applies: an ADR-0066
+ `tenancy.enabled: false` opt-out resolves to no organization at all (so a
+ platform-global object's audit trail is not scoped into one tenant and hidden
+ from the platform admin who acted), then a declared `tenancy.tenantField` when
+ the object really has that field, then the canonical injected
+ `organization_id`.
+
+ Most deployments see no change: under the `isolated` posture the Layer 0 wall
+ makes a cross-organization write of a walled object impossible, so the two
+ sides agree by construction. The behaviour changes under the `group` and
+ `shared` postures, and on system paths that write another organization's row
+ while carrying a session.
+
+ Not addressed here: `sys_api_key.active_organization_id` is still not
+ reachable by this resolver, so revocation rows on that object continue to fall
+ back to the actor's organization. Its column is deliberately not the object's
+ tenant-scope column and must not become one, so closing that half needs a
+ read-neutral, stamp-only organization declaration in `packages/spec`. #8707
+ remains open for it.
+- fe90efa: 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.
+- e9534a4: 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.
+- b348ac2: Published README documents the record-view audit surface that actually shipped
+
+ The README was corrected against the shipped surface before record-view auditing landed,
+ so it still told readers that "reads and views are not on the ledger" and that the plugin
+ takes no configuration. Both became false when the `read` action, its writer and the
+ `record_views` list view merged. This is a docs-only change; it needs a version bump
+ because the README is in the package's published `files` array, and a correction with no
+ release never reaches the npm package page at all.
+
+ What the page now documents, each point verified against the source rather than against a
+ description of it:
+
+ - the `read` action and its writer, in the action table and in the shipped list views;
+ - the per-object opt-in as what it is — an **install-time list** passed to the plugin
+ constructor (`new AuditPlugin({ readAudit: { objects: [...] } })`), explicitly **not** an
+ `enable.auditReads` object-metadata key. A declarable key can be set on an object in a
+ deployment that never installs the plugin, producing metadata that reads as audited and
+ writes nothing, which is the exact class of claim this page was corrected to remove;
+ - the three settings the plugin forwards, and the one writer knob (`maxBufferedEvents`) it
+ does not, which is reachable only by calling `installReadAuditWriter` directly;
+ - the record-detail discriminator that keeps list and search reads out of scope, including
+ the `$or` / `$not` refusal and the AND-composed predicate the security middleware leaves
+ behind;
+ - batched writes off the request path, the view-instant `created_at`, and the two loud
+ once-only failure postures (buffer overflow, failed ledger write);
+ - the two declared boundaries — a system-elevated read and a read with no principal both
+ write no row;
+ - that no field values are recorded, and therefore that the ledger cannot answer what a
+ viewer actually saw;
+ - that the shipped `record_views` view carries an `ip_address` column which is always empty
+ on a `read` row, because no read-path writer stamps it.
+
+ Record-view auditing adds no enterprise dependency: this package's declared edition is
+ `open`, and the opt-in is ordinary plugin configuration. The two enterprise-dependent
+ behaviours already annotated on the page — the hierarchy resolver and the archive
+ datasource — are unchanged.
+- bbd86ed: Attachment access hooks: read the caller's org under the blessed `organizationId` name
+
+ `callerContext()` in the `sys_attachment` access kit built its fallback
+ execution envelope from `session.tenantId` — an alias removed from the
+ hook/action session surface in v11 (#3290). `HookContextSchema` strips a
+ `tenantId` key and the engine's `buildSession` only ever emits
+ `organizationId`, so on every call that reached the session fallback (no
+ execution context riding along) the envelope handed to
+ `ISharingService.canEdit` carried **no organization at all**. Parent-record
+ access for attachments was therefore evaluated without the caller's active
+ org on that path. It now reads `session.organizationId`, matching the
+ `sys_comment` kit, which already did.
+
+ The `sys_comment` kit's own `callerContext()` had the same read as a dead
+ first arm (`s.tenantId ?? s.organizationId`); the arm is removed. That half
+ is behaviour-neutral — the fallback already carried the value.
+
+ Both kits gain coverage of the session-fallback path in both directions: the
+ blessed name is read, and a stray removed-alias key does not become the org.
+- d693ba1: Published README points at the `services.audit` reference again, in the form a published reader can follow (#9589)
+
+ PR #9531 dropped this README's "See Also" pointer to the runtime-services audit
+ page because the page was measured wrong — it documented `record()` /
+ `'set' | 'reset'` (the settings sink) as if that were the `audit` slot. PR #9587
+ rewrote the page around the real slot, so the reason for the omission has stopped
+ holding, and the link is restored.
+
+ It is restored because the page carries three things this README deliberately
+ does not, each verified against the page as it stands on `main` rather than
+ against the PR title that rewrote it:
+
+ - **the failure posture of the slot itself** — `recordAuthEvent` never throws; a
+ failed ledger insert is reported at `error` level once per process and then
+ drops to `debug`, the row is lost and nothing retries it, and the call silently
+ no-ops when no data engine resolves or when `userId` is absent. This README
+ documents the *record-view batcher's* two failure postures, which are a
+ different code path; it says nothing about this one.
+ - **the event's field-by-field shape** — that `userId` must be a real `sys_user`
+ id, that `sessionId` lands on `record_id` with `object_name` fixed to
+ `sys_session`, that `organizationId` stamps the tenant columns and an unstamped
+ row is one non-administrator members can never see, and that `context` is
+ serialized into `metadata`. This README states the slot's interface and its
+ closed `'login' | 'logout'` action union, and deliberately stops there.
+ - **the settings-sink disambiguation** — that `SettingsAuditSink.record()` is
+ never registered as or resolved from this slot, and that
+ `getService('audit').record({ ... })` therefore fails with a `TypeError`.
+
+ The restored line is **not** the line #9531 removed. That one read
+ `[Audit Logging Best Practices](/content/docs/kernel/runtime-services/audit-service.mdx)`
+ — a label describing a best-practices guide the page has never been, and a
+ repo-path-rooted URL that resolves for neither of this README's published
+ audiences. A README in the package's `files` array is rendered on npm and on
+ GitHub, where a root-relative href resolves against `npmjs.com` / `github.com`,
+ not against the docs site. The replacement uses the absolute
+ `https://docs.objectstack.ai/docs/...` form that `create-objectstack`'s published
+ READMEs already use, and its annotation states what the page adds — so the next
+ author weighing the same omission can check the justification instead of
+ reconstructing it.
+
+ The one pre-existing site-root-relative docs link in this same file
+ (`/docs/permissions/permission-sets#access-depth...`, added by the same PR) is
+ converted to the same absolute form. Its target page and heading anchor both
+ exist; only the spelling was unfollowable off the docs site.
+- 53fc099: docs(plugin-audit): the published README stops documenting an `auditService` API, a row shape and an action vocabulary that do not exist (#9517)
+
+
+
+ `packages/plugins/plugin-audit/README.md` is in the package's published `files`
+ array and `private` is unset, so it is **what the npm package page renders**. It
+ documented an API surface with no implementation anywhere in the repo, under a
+ banner claiming SOC 2 / HIPAA / GDPR readiness.
+
+ **Measured against `origin/main` before anything was rewritten**, and the drift
+ was wider than the ledger of it:
+
+ - **Every `auditService.*` method the README called is absent from the repo** —
+ `getFailedActions`, `logAdminAction`, `logDataAccess`, and also
+ `getRecordHistory`, `getUserActivity`, `searchLogs`, `getRecordSnapshot`,
+ `generateReport`, `archiveLogs`, `purgeLogs`, `logDataDeletion`,
+ `logDataExport`. Twelve methods, zero implementations. A reader following the
+ README wrote code that could not compile.
+ - **`PluginAudit` does not exist**, and neither does the `.configure({...})`
+ static it was called through — no class in this repo exposes one. The export is
+ `AuditPlugin`, a `Plugin` class registered as `kernel.use(new AuditPlugin())`
+ and taking **no configuration at all**. The documented config object
+ (`trackObjects`, `trackFields`, `retentionDays`, `autoArchive`, `excludeUsers`,
+ `trackSystemEvents`) was fabricated in full.
+ - **`IAuditService` is not in `@objectstack/spec/contracts`** — the README's
+ "Contract Implementation" section named an interface the spec has never
+ declared.
+ - **The row shape was not the shipped one.** The README declared `timestamp`,
+ `userName`, `userEmail`, `recordName`, `changes`, `sessionId`, `status` and
+ `errorMessage`. `sys_audit_log` declares none of them.
+ - **The action values were outside the enum.** `'insert'`, `'auth:login'`,
+ `'security:password_reset'`, `'workflow:approval'` and `'user_role_change'` are
+ not forms this object accepts; the namespaced-colon spelling never was one.
+ - **The object name was wrong** — `audit_log`, not `sys_audit_log`.
+ - **The REST namespace does not exist.** Six `/api/v1/audit/*` routes were
+ documented; the object declares `apiMethods: ['get', 'list']` and is read over
+ the ordinary object API.
+
+ The compliance paragraph is **deleted, not softened or relocated**: a
+ regulatory-readiness claim is a company-level statement needing an accountable
+ owner, and it does not belong in a package README. The three external
+ SOC 2 / GDPR / HIPAA links that existed only to support that framing are gone
+ with it.
+
+ The replacement documents only what the code can be pointed at: the real exports;
+ the real `sys_audit_log` columns; the seven-value action enum **with the writer
+ for each value**, so a reader can check any row of it; the credential masking on
+ `old_value` / `new_value`; and the coverage model, which is
+ **all objects minus an exclusion list** rather than the fabricated per-object
+ `trackObjects` config — subtraction, because the object universe is open and an
+ enumerated allow list would silently stop auditing everything registered after
+ boot.
+
+ Three things are now stated that the old README obscured, all of them gaps a
+ reader could otherwise mistake for coverage:
+
+ - **reads and views are not on the ledger** — no writer emits a read action;
+ - **failed operations are not on the ledger** — there is no success/failure
+ column, and the writers fire only on `after*` events, i.e. only on operations
+ that succeeded, so `getFailedActions`-style "security monitoring" had no
+ mechanism behind it in the first place;
+ - **`ip_address` / `user_agent` are populated on auth events only** — the
+ record-level writer does not stamp them, so a null client fingerprint on a CRUD
+ row does not mean the request had none.
+
+ Two dependency boundaries are **named with their degraded behaviour** rather than
+ left silent, following the `access-recipes.mdx` pattern: hierarchy-relative
+ permission scopes need `@objectstack/security-enterprise` and **fail closed to
+ `own`** without it, so a grant written to let managers read their reports' audit
+ rows shows them only their own on an open build; and `lifecycle.archive` needs a
+ registered `archive` datasource, **failing closed to retention** without one —
+ nothing is ever deleted and the table grows, which is the safe direction for a
+ ledger but not the documented one.
+- b3de42c: fix(audit): `record_views` list view drops its always-empty `ip_address` column, replaced with `actor` (#9539)
+
+ `sys_audit_log`'s `record_views` list view (the "who viewed this record" screen, #8992)
+ declared an `ip_address` column, but `buildRow` in `read-audit.ts` never stamps that key
+ on a `read` row — client-fingerprint fields are populated on auth events only. The column
+ was structurally empty on every row this view can ever show, which on a compliance
+ surface reads as "we captured the fingerprint and this request had none" rather than
+ "not captured" — the same 审计面宁窄勿谎 (narrow-not-untruthful) defect class #7675 /
+ #8147 / #8315 retired from this object's `action` enum, one layer down on a column.
+
+ Replaced with `actor`, which the read writer DOES stamp on every row and which attributes
+ a service principal (`svc:`) that `user_id` structurally cannot hold. Pinned by
+ `sys-audit-log-record-views-columns.test.ts`, which derives the read writer's actually-
+ stamped key set from a real engine run rather than a hand-copied list, so the class can't
+ regrow silently.
+
+ Maintainer ruling 2026-08-18 + triage auto-adjudication 2026-08-19 (both Option 1).
+ Stamping viewer IP (Option 2) was explicitly NOT commissioned in this change.
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [e374b4d]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [a751f7d]
+- Updated dependencies [eccb8b2]
+- Updated dependencies [650cd3d]
+- Updated dependencies [b735507]
+- Updated dependencies [91c6c28]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [4e71ae1]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [4dfa369]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [5e2f594]
+- Updated dependencies [e2899f6]
+- Updated dependencies [855591f]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [326f5de]
+- Updated dependencies [30d3752]
+- Updated dependencies [21995d7]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [6a5e6ad]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [b2a451f]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [7c2f386]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [8a9e7f4]
+- Updated dependencies [3d0ded8]
+- Updated dependencies [44bc51d]
+- Updated dependencies [1258dca]
+- Updated dependencies [91c4ff5]
+- Updated dependencies [d634e66]
+- Updated dependencies [682b86b]
+- Updated dependencies [6a1b45e]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/objectql@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/plugins/plugin-audit/package.json b/packages/plugins/plugin-audit/package.json
index c1ac046c41..7b5ad1bbba 100644
--- a/packages/plugins/plugin-audit/package.json
+++ b/packages/plugins/plugin-audit/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/plugin-audit",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Audit Plugin for ObjectStack — System audit log object and audit trail",
"main": "dist/index.js",
diff --git a/packages/plugins/plugin-auth/CHANGELOG.md b/packages/plugins/plugin-auth/CHANGELOG.md
index 1113112f98..e710814336 100644
--- a/packages/plugins/plugin-auth/CHANGELOG.md
+++ b/packages/plugins/plugin-auth/CHANGELOG.md
@@ -1,5 +1,663 @@
# Changelog
+## 17.1.0
+
+### Minor Changes
+
+- e43d63a: feat(identity): API keys are minted against the minter's active organization, and carry it into the request (#8287)
+
+
+
+ On a deployment running `OS_TENANCY_POSTURE=isolated`, a minted API key could
+ read **nothing at all**. `sys_api_key` carried no organization column, so key
+ authentication established a user but no active organization — and the
+ `isolated` Layer 0 wall is `organization_id = activeOrganizationId`, which with
+ no active organization matches no row. Every organization-scoped read answered
+ `200` with `total 0` while the console went on offering minting, so a tenant
+ admin could mint a valid-looking secret and discover only at call time that it
+ read nothing. (There was no cross-tenant leak — the failure was in the other
+ direction.)
+
+ **The column was absent by an inherited rule, not by oversight.**
+ `resolveInjectedSystemColumns` injects `organization_id` into every registered
+ object *except* `managedBy: 'better-auth'` ones, and `sys_api_key` carries that
+ flag — even though better-auth's `apiKey` plugin is not loaded and the table is
+ hand-rolled ObjectStack. So the fix needs the declaration *and* the ADR-0105 D7
+ extension-field registration to stay consistent. The read side, by contrast,
+ was **already wired**: `resolveApiKeyPrincipal` already read an organization
+ into `tenantId` and `resolveAuthzContext` already adopted it — it was reading a
+ column no mint path ever wrote.
+
+ **What changes**
+
+ - `sys_api_key` declares `active_organization_id` (+ index, and the column is
+ shown in the "My Keys" and "All" list views, because the card's complaint was
+ a credential whose reach its owner could not see).
+ - `POST /api/v1/keys` **inherits** the caller's active organization — there is
+ deliberately no org parameter and no cross-org key — and **re-checks the
+ caller's `sys_member` membership at mint time**, honouring ADR-0091 validity
+ windows. Under a walled posture it refuses (400) rather than minting a key
+ with no organization, and refuses (403) for an organization the caller is not
+ a member of. The mint response echoes the organization the key is pinned to.
+ - The verifier reads **one spelling** (Prime Directive #12): the
+ `row.organization_id ?? row.organizationId` chain it used to carry was a
+ consumer-side tolerance for a producer that did not exist.
+ - An **ex-member's key fails closed at verify time** — no principal, not a
+ degrade to a user-only principal, which would resurrect the same
+ `200 + total 0` silent-empty. Checked at verify rather than by revoking on
+ membership loss, because membership ends through many paths (better-auth org
+ endpoints, SCIM, a direct `sys_member` delete, a lapsing validity window) and
+ a hook must catch every one or it silently misses. It costs **zero extra
+ queries**: the resolver has already read `sys_member` for this user.
+ - **Pre-existing org-less keys are never backfilled** — that would silently
+ upgrade credentials minted under a different promise. They keep working under
+ `single` (no wall) and under `group` (whose wall derives from the owner's
+ memberships independently of the active organization, so they already work
+ there), and are **refused under `isolated`**, where they are provably dead
+ today.
+
+ **The column is deliberately named `active_organization_id`, not
+ `organization_id`** — the `sys_session` spelling, for the same concept: the
+ organization a credential makes *active*. `objectHasOrgIdField` tests for the
+ literal `organization_id`, and Layer 0 exempts objects without it, so the other
+ name would have made `sys_api_key` itself org-walled. Both walled postures
+ exclude NULL, so every pre-existing org-less row would have vanished from its
+ **own owner's** "My Keys" list while, under `group`, continuing to
+ authenticate — a live credential nobody could see or revoke, which is a fresh
+ instance of the very class this change removes.
+- 5f5e234: fix(security): `sys_permission_set.active` and `sys_position.active` now actually stop granting access (#8613)
+
+
+
+ **BREAKING for deployments that already switched a permission set or position
+ off.** Both objects ship a Deactivate action whose confirmation dialog promises,
+ in all four locales, that access stops:
+
+ > Deactivate this permission set? Existing assignments stay in place but stop
+ > granting access until re-activated.
+ > Deactivate this position? Users keep their assignment but the position stops
+ > granting permissions until re-activated.
+
+ Nothing read the column. Measured on the real resolver: a position seeded
+ `active: false` still granted its permission sets, and a permission set seeded
+ `active: false` still returned `posture: PLATFORM_ADMIN` with its system
+ permissions. Deactivation moved a badge in Setup and nothing else — while the
+ admin who had just revoked a compromised or over-broad grant was told the
+ opposite, and whose likely next step was therefore *not* the action that would
+ have worked (delete the set, or remove the assignments).
+
+ **What changes at runtime.** `resolveAuthzContext` / `resolveUserAuthzGrants`
+ (`@objectstack/core`) — the single seam every transport resolves authorization
+ through — now drop a deactivated row **before** any derivation:
+
+ - a deactivated `sys_position` no longer contributes its
+ `sys_position_permission_set` grants, and its name leaves `positions` (so the
+ name-reuse path cannot resolve the same grant one layer down);
+ - a deactivated `sys_permission_set` contributes no name, no
+ `system_permissions`, no `tab_permissions`, **and no `PLATFORM_ADMIN`
+ posture** — the flag is applied before the posture is derived, not after;
+ - the `plugin-security` DB loader applies the same predicate, which is what
+ judges a set reached by NAME through an active position of the same name.
+
+ Both tables were already read at that seam, so this costs **zero new hot-path
+ queries**.
+
+ **⚠️ Read this before upgrading.** Any `sys_permission_set` or `sys_position`
+ row currently carrying `active: false` **stops granting the moment this
+ lands** — on live data, with no migration step to notice. That is the correct
+ direction (it is what the dialog said when someone clicked Deactivate), but on
+ an installation that used the switch believing it was inert it is a real
+ revocation. Before upgrading, list the deactivated rows and re-activate any that
+ are still meant to grant:
+
+ ```
+ GET /api/v1/data/sys_permission_set?filters=[["active","=",false]]
+ GET /api/v1/data/sys_position?filters=[["active","=",false]]
+ ```
+
+ A row whose `active` column is **absent or NULL** is unaffected: the predicate
+ is "explicitly deactivated", never "explicitly active", so rows that predate the
+ column keep granting exactly as before.
+
+ **Break-glass, closed in the same change** (`@objectstack/plugin-auth`).
+ Enforcing the flag opened a one-click, installation-wide lockout: deactivating
+ `admin_full_access` un-makes every platform admin at once, through a payload
+ that touches neither `name` nor any identity table, and re-activating requires
+ the permission the click just took away (the seeders deliberately never
+ reconcile `active`, so no restart restores it). The last-administrator guard now
+ judges that write like the delete and rename spellings it already refused, and
+ an environment whose break-glass set is *already* off is read as emptied rather
+ than as a bootstrap window — so it does not silently disarm the guard for every
+ other identity write. Re-activation itself stays permitted, or the refusal would
+ have no way out from inside the product.
+- f8eb736: feat(security): bind the break-glass standing-key lists to what the authz resolver actually reads — the correspondence stops being prose (#8734)
+
+ `plugin-auth`'s last-administrator guard (ADR-0024 D5.2) decides whether a
+ pending write can empty the administrator population by testing the payload
+ against three standing-key lists (`MEMBER_STANDING_KEYS`,
+ `GRANT_STANDING_KEYS`, `PERMISSION_SET_STANDING_KEYS`). A payload touching none
+ of them is skipped without any reads — so a column `resolveAuthzContext` starts
+ reading that a list omits is a write class the guard **silently stops judging**,
+ on the one path whose failure mode is an installation-wide administrator lockout
+ with no in-product recovery.
+
+ Nothing bound the two together. The correspondence lived in a comment, and it
+ had already gone false once: #6084 wrote — naming `active` explicitly — that
+ everything a permission-set write touches other than `name` is invisible to "who
+ is an administrator". That was true when written; #8613 made `active` a
+ resolution-time predicate and the sentence became false. Nothing mechanical
+ would have caught it, because the guard's own tests stay green precisely when
+ the guard is never consulted.
+
+ **The mechanism is two links, and the first one is a measurement.**
+
+ - `@objectstack/core` now exports `ADMIN_STANDING_SURFACE` — declared beside the
+ resolver, listing every table the administrator-derivation path reads, each
+ classified `derives` or `reads-only` with its reason, and for the deriving
+ tables every column read. It is asserted **equal** to what the real
+ `resolveAuthzContext` reads, observed at runtime through a recording engine
+ that records every property access and every `where` key per table. Observation
+ rather than source extraction because the reads that matter have moved into
+ helpers: `active` is read by `isRowActive(row)` and the ADR-0091 window bounds
+ by `isGrantActive(row, now)`, neither named at the resolver's own call site —
+ the exact shape #8613 had.
+
+ - `@objectstack/plugin-auth` now exports its standing-key lists plus
+ `STANDING_KEYS_BY_TABLE` and `STANDING_KEY_EXCLUSIONS`, and a gate requires
+ every column of that measured surface to have an answer: it is standing-bearing
+ (in a list) or it is excluded with the reason it cannot empty the administrator
+ population. There is no third state — the third state is what `active` was
+ between #6084 and #8613.
+
+ So a resolver change that starts reading a new column fails at the first link
+ until the declaration is updated, and at the second until the guard has an
+ explicit answer for it. Landing #8613 green would have required writing down that
+ deactivating `admin_full_access` cannot empty the administrator population —
+ which is false, and which is what the old comment asserted by accident.
+
+ **No guard behaviour changes.** Every list keeps exactly the values it had; the
+ gate is one-directional by construction (it can only ever demand that the guard
+ judges *more*), because the other direction would put pressure on a break-glass
+ guard to fire less often.
+
+ The table-level half is covered too: a resolver that started deriving
+ administrator standing from a **new** table is invisible to any column-set
+ comparison, since the table is absent from both sides — so the surface enumerates
+ every table the path reads, and an unclassified one fails.
+
+### Patch Changes
+
+- c9f5950: fix(security): `sys_account`'s OAuth access/refresh/id tokens stop serializing on the data API — `internal: true`, with better-auth's readback seam widened to cover them (#7987)
+
+
+
+ `sys_account.access_token`, `.refresh_token` and `.id_token` hold each user's
+ **live third-party OAuth credentials** — the tokens ObjectStack received from
+ Google, GitHub or an OIDC IdP — in cleartext (better-auth's
+ `account.encryptOAuthTokens` is not set, so `setTokenUtil` stores them
+ verbatim). They were plain `Field.textarea` on an object declaring
+ `apiEnabled: true, apiMethods: ['get','list']`.
+
+ **Both personas were measured leaking, on a real booted stack** (`bootStack(showcaseStack)`,
+ in-process HTTP + sqlite-wasm), with a planted token on a member's account row:
+
+ - **admin**, `GET /data/sys_account/{another user's account id}` — 200, that
+ member's `refresh_token` verbatim, plus `access_token` and `id_token`;
+ - **member**, `GET /data/sys_account` (self-scoped by the `sys_account_self` RLS
+ policy) — 200, their **own** `refresh_token` verbatim.
+
+ The member arm is the one this object does not share with its `sys_session`
+ sibling (#7823), and it is the sharper of the two: it converts a short-lived,
+ revocable ObjectStack session bearer into a **long-lived third-party refresh
+ token that this platform cannot revoke at all**. Neither collector reached these
+ columns — the engine's credential mask collects by field TYPE (`textarea` is
+ neither `secret` nor `password`) *and* exempts objects with
+ `managedBy: 'better-auth'`, which this object is.
+
+ **The fix is three declarations plus one widening**, inheriting #7823's shape
+ rather than inventing a second mechanism:
+
+ - the three columns are declared `internal: true` — the opt-in, type-independent
+ flag minted by #7728 meaning *the declared value is never returned on the
+ generic data path*. Storage, filtering and indexing are untouched: the strip
+ runs on rows the driver has already produced.
+ - better-auth **reads these back off adapter result rows** — measured, and the
+ risk this card was parked on: `internalAdapter.findAccounts(userId)` issues a
+ `findMany` with no projection, and `/get-access-token`, `/account-info` and
+ `/refresh-token` then read `account.refreshToken` / `.accessToken` /
+ `.idToken` off those rows. The read strip alone would answer
+ `REFRESH_TOKEN_NOT_FOUND` (400) and hand back an empty access token. So the
+ existing readback seam in `@objectstack/plugin-auth` — which already recovered
+ `sys_session.token` through `Engine.resolveInternalField` (#8118's privileged
+ batch accessor) — is widened to cover these three columns and renamed
+ accordingly. No engine carve-out, no second accessor.
+
+ **Not retyped, deliberately.** `Field.secret()` would route better-auth's own
+ writes through the engine's encrypt-on-write path, placing the engine between
+ better-auth and its own adapter. `Field.password()` is inert here for the two
+ reasons above.
+
+ **`password` / `previous_password_hashes` are deliberately out of scope** —
+ they are better-auth one-way hashes (ADR-0100's third channel), not reversible
+ outbound credentials, and the readback seam refuses to touch them.
+
+ The regression proof drives both directions: the fixture PLANTS real token
+ values and re-reads them out of storage through the privileged accessor before
+ asserting anything (so "absent from the response" cannot pass vacuously), then
+ pins that the values are still on disk, still usable as a server-side predicate,
+ and that password sign-in — which reads a `sys_account` row back through the
+ same seam on every request — still works.
+- d6e80b2: fix(security): `sys_account.password` and `previous_password_hashes` stop serializing on the data API — `internal: true`, with the raw-engine readers converted to the privileged accessor (#8676)
+
+
+
+ `sys_account.password` (the credential hash) and `previous_password_hashes` (the
+ ADR-0069 D1 reuse-prevention ring) serialized on `/api/v1/data/sys_account`,
+ which declares `apiEnabled: true, apiMethods: ['get','list']` — to an **admin
+ for every user's row**, and to a **member for their own** (the
+ `sys_account_self` RLS policy grants `select` on `user_id == current_user.id`).
+
+ These are one-way hashes, not reversible outbound credentials — which is why
+ #7987 correctly refused to bundle them with the OAuth tokens. But a served
+ password hash is an offline-cracking target, and `previous_password_hashes`
+ multiplies it by the history ring while its own declaration says it is *never
+ exposed in UI*. This is the disposition #7728 already reached for
+ `sys_api_key.key`, which was **also** a stored hash and was still ruled unfit to
+ serialize through the API face.
+
+ Neither credential collector could reach them: `collectMaskedReadFields` keys on
+ the field **TYPE** (`secret` / `password`) *and* exempts objects declaring
+ `managedBy: 'better-auth'`, which this object is — while these columns are
+ `text` / `textarea`. Two independent barriers, both missing.
+
+ **The fix is two declarations plus two recovery seams**, and the second seam is
+ the part a bare flag would have missed:
+
+ - both columns are declared `internal: true` — the opt-in, type-independent flag
+ from #7728 meaning *the declared value is never returned on the generic data
+ path*. Storage, filtering and indexing are untouched: the strip runs on rows
+ the driver has already produced.
+ - **better-auth's adapter readers** are recovered by the existing per-object
+ readback table, widened with `password`: the sign-in verifier compares against
+ the hash on the row `internalAdapter.findCredentialAccount(userId)` returns,
+ so the strip alone would break password sign-in for every user.
+ - **plugin-auth's own RAW-engine readers** are recovered by a new seam in the
+ same module, `recoverInternalFieldsForSystemRead`. This is the half that makes
+ the flag safe: the readback table is imported by exactly one file
+ (better-auth's storage adapter), so it cannot reach a caller that reads the
+ engine directly — and the engine's strip has **no `isSystem` carve-out** by
+ #7728's design. Measured against a real ObjectQL engine: the reuse ring's
+ `findOne` returns `{"id":"a1"}` for a query that names both columns in an
+ explicit projection under `context: { isSystem: true }`.
+
+ Left unrecovered, `assertPasswordNotReused` would become a **silent no-op** —
+ its comparison list empties, the loop never runs, `PASSWORD_REUSE` is never
+ thrown, and its own `catch { return undefined }` means nothing announces it.
+ The ADR-0069 D1 control would report success while accepting every reused
+ password. Its unit tests would have stayed green throughout, because they use
+ fake engines that never apply the strip.
+
+ **No ADR-0100 guard change, and none was needed.** `Engine.resolveInternalField`
+ has exactly one predicate — `internal === true` — so flagging the columns makes
+ them legitimately dereferenceable through the privileged accessor. The ADR-0100
+ sentence in its refusal message is prose explaining why a *non-flagged* field has
+ other channels, not a second predicate; the guard stays exactly as selective as
+ it was, and a non-flagged column on the same object is still refused with
+ `INVALID_FIELD` / 400.
+
+ Regression proof drives both directions on a real booted stack: both columns are
+ absent for both personas — including a caller who spells them out in `?select=` —
+ while the values remain on disk and reachable through the privileged accessor,
+ password sign-in still works, and the reuse ring still grows across a password
+ change on every transport lane.
+- 445ae4d: 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.
+- 03520eb: 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.
+- 7337f30: 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.
+- 5ed8ee6: Platform admins can ban and unban users again.
+
+ `POST /api/v1/auth/admin/ban-user` and `POST /api/v1/auth/admin/unban-user` are
+ now served by ObjectStack with the ADR-0068 platform-admin gate instead of
+ better-auth's `admin` plugin, which authorizes on the legacy
+ `user.role === 'admin'` scalar that ADR-0068 D2 stopped synthesizing. On any
+ deployment with the admin plugin on (SCIM forces it, ADR-0071) the `sys_user`
+ Ban / Unban actions returned `403 YOU_ARE_NOT_ALLOWED_TO_BAN_USERS` for every
+ platform admin; they now succeed, and refuse a plain member with
+ `403 PERMISSION_DENIED` and an anonymous caller with `401 UNAUTHENTICATED`.
+
+ The break-glass guard that refuses to ban the last local-password login is
+ unchanged and still applies.
+- 04f8fdb: fix(platform-objects): drop the dead `mapId` ("Map: User ID claim") param from `register_sso_provider` — the OIDC subject claim is not configurable (#8222)
+
+
+
+ The `register_sso_provider` action on `sys_sso_provider` offered an optional
+ **"Map: User ID claim"** text field (`mapId`), with helpText reading *"Optional.
+ ID-token claim mapped to the user ID. Defaults to `sub`."*
+
+ **That capability no longer exists.** It was retired upstream in
+ `@better-auth/sso@1.7.0-rc.2`:
+
+ - `oidcConfig.mapping` is a `z.strictObject` whose members are
+ `{ email, emailVerified?, name, image?, extraFields? }` — there is no `id`;
+ - the federated subject is hard-wired to the OIDC `sub` claim
+ (`id: readStringClaim(rawUserInfo, "sub")` and `id: idToken.sub`), then
+ cross-checked (`id_token_subject_missing`,
+ `id_token_userinfo_subject_mismatch`);
+ - `extraFields` is not an escape hatch — it is spread **before** `id` in the
+ profile literal, so an `extraFields.id` is overwritten by `sub` before anything
+ reads it.
+
+ `1.6.20` did honour `mapping.id` (`id: rawUserInfo[mapping.id || "sub"]`); the
+ version bump deleted the member.
+
+ So the field's only accepted values were "empty" and the `sub` it already
+ defaulted to. #8193 (PR #8221) stopped the bridge emitting the retired key and —
+ rather than accept a value it would silently discard — made a non-`sub` value
+ answer `INVALID_REQUEST`. That left the last half of the problem: **the form
+ still advertised a free-form optional field that 400s on anything meaningful.**
+ Removing it restores declared = enforced. Nothing else about registration moves:
+ the runtime accept set is unchanged, and a registration that never sent `mapId`
+ behaves exactly as before.
+
+ `mapEmail` and `mapName` are untouched — they map to live `oidcMappingSchema`
+ members and are still honoured.
+
+ **The bridge-side guard in `plugin-auth`'s `register-sso-provider.ts` is kept**,
+ and its refusal test with it. The admin form was only one caller: a direct API
+ client, a script, or a stale cached console bundle can still put `mapId` on the
+ wire, and telling those callers plainly still beats discarding the value in
+ silence. Only the guard's doc comment changed, to stop describing `mapId` as a
+ field the form sends.
+
+ The generated translation bundles (`*.objects.generated.ts`, all four locales)
+ were **regenerated**, not hand-edited, so the retired label disappears from every
+ locale rather than lingering as a stale entry.
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [e7bccaa]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [5047cb8]
+- Updated dependencies [ed4ca59]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2d0af57]
+- Updated dependencies [420804d]
+- Updated dependencies [51a46a4]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [3ab2488]
+- Updated dependencies [2b292ce]
+- Updated dependencies [185c7bd]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [66dbec4]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [45862a5]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [b537855]
+- Updated dependencies [2065e31]
+- Updated dependencies [6cb88d9]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4dc8a61]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [b6c7690]
+- Updated dependencies [e6e1de4]
+- Updated dependencies [6a12e5e]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [9e2e682]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [499f55e]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [7fc01db]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/rest@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json
index e784d85f19..401327f704 100644
--- a/packages/plugins/plugin-auth/package.json
+++ b/packages/plugins/plugin-auth/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/plugin-auth",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Authentication & Identity Plugin for ObjectStack",
"main": "dist/index.js",
diff --git a/packages/plugins/plugin-dev/CHANGELOG.md b/packages/plugins/plugin-dev/CHANGELOG.md
index b1e5fcfb5a..dfeae0ec25 100644
--- a/packages/plugins/plugin-dev/CHANGELOG.md
+++ b/packages/plugins/plugin-dev/CHANGELOG.md
@@ -1,5 +1,225 @@
# @objectstack/plugin-dev
+## 17.1.0
+
+### Patch Changes
+
+- 593c4bf: feat(spec): `storage` becomes the canonical `CoreServiceName` slot; `file-storage` stays a deprecated v17 alias (#9683)
+
+
+
+ Maintainer ruling, 2026-08-18, verbatim: 「9683 file-storage 可以叫 storage」.
+ The `file-storage` slot was the only `CoreServiceName` member whose spelling
+ diverged from its documented accessor (`services.storage`), with no recorded
+ reason anywhere in the tree.
+
+ - `CoreServiceName` gains `storage` as the canonical member; `file-storage`
+ stays an accepted, deprecated alias within v17 (it is a published enum
+ member — existing `getService('file-storage')` callers keep working).
+ `CORE_SERVICE_PROVIDER` and `ServiceRequirementDef` carry both.
+ - `@objectstack/service-storage` registers the **same instance** under both
+ names (the `http.server` / `http-server` pattern), pinned by an
+ alias-equivalence test.
+ - Every internal consumer resolves `storage`: the HTTP dispatcher, the email
+ plugin's attachment store, and `os migrate files-to-references`. Discovery
+ reports the service under the canonical `storage` key and mirrors the row
+ verbatim under the `file-storage` key for the alias's v17 lifetime, so
+ existing discovery readers (e.g. the console endpoint catalog) keep
+ working.
+ - Docs (`kernel/runtime-services`, `kernel/contracts`) now document the
+ canonical slot; a custom v17 provider for this slot should register both
+ names.
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [ca2e020]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [e7bccaa]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [e374b4d]
+- Updated dependencies [5047cb8]
+- Updated dependencies [ed4ca59]
+- Updated dependencies [445ae4d]
+- Updated dependencies [a433122]
+- Updated dependencies [bc6434b]
+- Updated dependencies [96f397a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [48032c9]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [a751f7d]
+- Updated dependencies [eccb8b2]
+- Updated dependencies [650cd3d]
+- Updated dependencies [b735507]
+- Updated dependencies [91c6c28]
+- Updated dependencies [75b7c24]
+- Updated dependencies [cf0d902]
+- Updated dependencies [498f4e8]
+- Updated dependencies [cc5c07b]
+- Updated dependencies [d9813a9]
+- Updated dependencies [4c178c1]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [6a51704]
+- Updated dependencies [2d0af57]
+- Updated dependencies [7337f30]
+- Updated dependencies [420804d]
+- Updated dependencies [8656d67]
+- Updated dependencies [51a46a4]
+- Updated dependencies [c8e85fc]
+- Updated dependencies [3d61924]
+- Updated dependencies [5244fd7]
+- Updated dependencies [716ac9b]
+- Updated dependencies [e9534a4]
+- Updated dependencies [6feac91]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [b2789ad]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [4ea921c]
+- Updated dependencies [0ccea4a]
+- Updated dependencies [3ab2488]
+- Updated dependencies [2b292ce]
+- Updated dependencies [185c7bd]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [66dbec4]
+- Updated dependencies [6aceca9]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [45862a5]
+- Updated dependencies [152bff8]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [4e71ae1]
+- Updated dependencies [739fe5b]
+- Updated dependencies [20067c5]
+- Updated dependencies [5ed8ee6]
+- Updated dependencies [e783e16]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [b537855]
+- Updated dependencies [2065e31]
+- Updated dependencies [6cb88d9]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4dc8a61]
+- Updated dependencies [4d47afe]
+- Updated dependencies [4fc4a3c]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [4dfa369]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [5e2f594]
+- Updated dependencies [e2899f6]
+- Updated dependencies [bbd86ed]
+- Updated dependencies [b6c7690]
+- Updated dependencies [855591f]
+- Updated dependencies [e6e1de4]
+- Updated dependencies [6a12e5e]
+- Updated dependencies [3851f87]
+- Updated dependencies [c73eacd]
+- Updated dependencies [712e185]
+- Updated dependencies [88e1bac]
+- Updated dependencies [693c788]
+- Updated dependencies [2a29caa]
+- Updated dependencies [9e2e682]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [0425db9]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [326f5de]
+- Updated dependencies [30d3752]
+- Updated dependencies [21995d7]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [499f55e]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [6a5e6ad]
+- Updated dependencies [30b1c63]
+- Updated dependencies [7fc01db]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [c86799f]
+- Updated dependencies [5989b0d]
+- Updated dependencies [19db5fa]
+- Updated dependencies [2b9d33a]
+- Updated dependencies [ad217b1]
+- Updated dependencies [f01c0ee]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [b2a451f]
+- Updated dependencies [c25b2d5]
+- Updated dependencies [147eadc]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [7c2f386]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [8a9e7f4]
+- Updated dependencies [3d0ded8]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [1258dca]
+- Updated dependencies [91c4ff5]
+- Updated dependencies [d634e66]
+- Updated dependencies [682b86b]
+- Updated dependencies [6a1b45e]
+ - @objectstack/spec@17.1.0
+ - @objectstack/plugin-auth@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/runtime@17.1.0
+ - @objectstack/plugin-security@17.1.0
+ - @objectstack/rest@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/objectql@17.1.0
+ - @objectstack/driver-memory@17.1.0
+ - @objectstack/plugin-hono-server@17.1.0
+ - @objectstack/service-storage@17.1.0
+ - @objectstack/service-i18n@17.1.0
+ - @objectstack/account@17.1.0
+ - @objectstack/setup@17.1.0
+ - @objectstack/service-realtime@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json
index 55b3e69b91..8d1071ca70 100644
--- a/packages/plugins/plugin-dev/package.json
+++ b/packages/plugins/plugin-dev/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/plugin-dev",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Development Assembly Plugin for ObjectStack — wires the real platform stack for zero-config local development",
"main": "dist/index.js",
diff --git a/packages/plugins/plugin-email/CHANGELOG.md b/packages/plugins/plugin-email/CHANGELOG.md
index 5f8b27be13..eae651dcda 100644
--- a/packages/plugins/plugin-email/CHANGELOG.md
+++ b/packages/plugins/plugin-email/CHANGELOG.md
@@ -1,5 +1,281 @@
# @objectstack/plugin-email
+## 17.1.0
+
+### Minor Changes
+
+- 23abe27: 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.
+
+### Patch Changes
+
+- 445ae4d: 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.
+- 7337f30: 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.
+- e9534a4: 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.
+- 593c4bf: feat(spec): `storage` becomes the canonical `CoreServiceName` slot; `file-storage` stays a deprecated v17 alias (#9683)
+
+
+
+ Maintainer ruling, 2026-08-18, verbatim: 「9683 file-storage 可以叫 storage」.
+ The `file-storage` slot was the only `CoreServiceName` member whose spelling
+ diverged from its documented accessor (`services.storage`), with no recorded
+ reason anywhere in the tree.
+
+ - `CoreServiceName` gains `storage` as the canonical member; `file-storage`
+ stays an accepted, deprecated alias within v17 (it is a published enum
+ member — existing `getService('file-storage')` callers keep working).
+ `CORE_SERVICE_PROVIDER` and `ServiceRequirementDef` carry both.
+ - `@objectstack/service-storage` registers the **same instance** under both
+ names (the `http.server` / `http-server` pattern), pinned by an
+ alias-equivalence test.
+ - Every internal consumer resolves `storage`: the HTTP dispatcher, the email
+ plugin's attachment store, and `os migrate files-to-references`. Discovery
+ reports the service under the canonical `storage` key and mirrors the row
+ verbatim under the `file-storage` key for the alias's v17 lifetime, so
+ existing discovery readers (e.g. the console endpoint catalog) keep
+ working.
+ - Docs (`kernel/runtime-services`, `kernel/contracts`) now document the
+ canonical slot; a custom v17 provider for this slot should register both
+ names.
+- 6158146: Stop serving custom email headers through the generic data-API read of `sys_email` (#8149).
+
+ **What this closes.** `sys_email.headers_json` — the custom headers handed to `IEmailService.send`, the ordinary place a relay credential or provider token goes — was readable by every caller the data API admits (list, get, an explicit `?select=headers_json`). The column is now declared `internal: true`, so the engine omits it from every generic read with no system carve-out (#7728); `SYSTEM_CTX` does not reopen it either. This is the same shape #8118 ruled on for `sys_http_delivery.headers_json`: this change adopts that remedy rather than deciding it a second time.
+
+ **Delivery is unaffected, and fail-closed.** `sys_email` is not delivered from the in-memory message but FROM THE ROW: the after-insert outbox drain hook, the `email.send.async` queue subscriber and the boot outbox sweep all re-read the row and hand it to `EmailService.deliverPersistedRow`. All three read through `engine.find`, which is exactly what the flag empties — so the recovery ships with the flag. `deliverPersistedRow` now recovers the column through ObjectQL's privileged accessor (`resolveInternalField`, consumed unchanged) and sends every authored header verbatim. A message whose headers cannot be recovered is NOT sent without them: a missing header is not self-announcing — a relay that does not require it accepts the mail while the delivery silently deviates from the authored configuration. That case throws and leaves the row `queued`, not `failed`, so the queue retry or the next boot's sweep delivers it intact.
+
+ **New optional seam.** `EmailPersistence.readHeadersJson(rowIds)` — the readback the plugin wires off the raw engine. It probes the OBJECT SCHEMA flag, never the absence of the key from a result row: `headers_json` is `required: false` and most real rows carry no custom headers at all, so a key-absence inference would treat every ordinary email as redacted (the regression measured on `sys_account`'s optional token columns in #7987/PR #8675). Engines that do not redact are left untouched and trigger no privileged read.
+
+ **What this deliberately does NOT close.** The row still holds the header map in cleartext at rest. Encrypting it (`Field.secret()`) was measured and rejected on #8118 — an orphan `sys_secret` row per message with no cascade or retention, a boot-window fail-open, and a per-row decrypt on every delivery — and this change adopts that ruling unchanged.
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/formula@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json
index 3bf5d763d0..864192f2f5 100644
--- a/packages/plugins/plugin-email/package.json
+++ b/packages/plugins/plugin-email/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/plugin-email",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Email service plugin for ObjectStack — IEmailService + transport-pluggable outbound delivery with sys_email persistence.",
"main": "dist/index.js",
diff --git a/packages/plugins/plugin-hono-server/CHANGELOG.md b/packages/plugins/plugin-hono-server/CHANGELOG.md
index f7bddc93bd..369a0e42e7 100644
--- a/packages/plugins/plugin-hono-server/CHANGELOG.md
+++ b/packages/plugins/plugin-hono-server/CHANGELOG.md
@@ -1,5 +1,280 @@
# @objectstack/plugin-hono-server
+## 17.1.0
+
+### Minor Changes
+
+- 152bff8: 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.
+- 88e1bac: fix(api): the plugin-mounted Hono error paths answer the declared envelope — six refusal bodies stop speaking the pre-#3675 dialect (#9364)
+
+ Six hand-built refusal bodies on plugin-mounted Hono routes departed from
+ `BaseResponseSchema`. They were invisible to every check in the repo until
+ #9267 added the gate's third surface, which discovers these routes by parsing
+ rather than by filename. This converts the **error-path** half of what that
+ first run measured; the bare pre-auth discovery payloads it also found are a
+ separate wire ruling (#9389) and are untouched here.
+
+ **If you branch on these bodies, this is the change.** Every one of them was
+ readable only by reaching for a key the contract does not declare, so no
+ consumer that followed `ApiErrorSchema` was reading them successfully in the
+ first place — `body.error.message` read `undefined` on all six.
+
+ `@objectstack/plugin-hono-server` — the adapter's own refusals, the answer any
+ host using it as its transport gets for an unmatched request or a handler that
+ produced nothing:
+
+ | status | was | now |
+ |:--|:--|:--|
+ | 404 unmatched path | `{ error: 'Not found' }` | `{ success: false, error: { code: 'ENDPOINT_NOT_FOUND', message: 'Not found' } }` |
+ | 405 method mismatch | `{ error, code, message, method, path, allowed }` | `{ success: false, error: { code: 'METHOD_NOT_ALLOWED', message, details: { method, path, allowed } } }` |
+ | 500 handler wrote nothing | `{ error: 'No response from handler' }` | `{ success: false, error: { code: 'INTERNAL_ERROR', message: 'No response from handler' } }` |
+ | 500 fallback threw | `{ error: 'Fallback handler failed' }` | `{ success: false, error: { code: 'INTERNAL_ERROR', message: 'Fallback handler failed' } }` |
+
+ The 405 is the sharpest of the four: it already carried a real semantic code,
+ but placed it BESIDE `error` rather than inside it, so `body.error.code` read
+ `undefined` while `body.code` worked — the #7035 dialect. Its `code` **value**
+ is unchanged (`METHOD_NOT_ALLOWED`, a `StandardErrorCode` member); only its
+ position moved, along with the three context keys, which are now
+ `error.details` — the slot `ApiErrorSchema` declares for exactly that. The
+ `Allow` header is unchanged and remains the primary channel for it.
+
+ `@objectstack/hono` — the shared `errorJson` helper wrote the HTTP **status**
+ into `error.code`, so every refusal from this mount shipped `error.code: 404`
+ or `500` where `ApiErrorSchema.code` declares a closed STRING vocabulary
+ (ADR-0112 D3/D4). It now derives the standard member for the status through
+ `resolveThrownHttpError` (`@objectstack/types`) — the one rule the REST and
+ dispatcher doors already read for this question, so this third door does not
+ become a fourth dialect. A 404 from this mount now carries
+ `error.code: 'RESOURCE_NOT_FOUND'`; the numeric status stays where it is
+ authoritative, on the response line.
+
+ `@objectstack/cli` — the unbound-hostname 404 from `os serve`'s
+ `OS_ROOT_DOMAIN` guard answered
+ `{ error: 'environment_not_found', message, hostname }`: a bare-string error
+ with two stray top-level keys, and a lowercase code where error codes are
+ `SCREAMING_SNAKE`. It is now
+ `{ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message, details: { hostname } } }`.
+ The `Accept: text/html` branch still serves the styled 404 page, unchanged.
+
+ **The cross-adapter reference implementation moved with it.**
+ `@objectstack/http-conformance`'s zero-dependency `NodeHttpServer` mirrors the
+ adapter's unmatched-request bodies byte-for-byte on purpose — the whole point
+ of that package is proving the transport port is free of framework-isms, and
+ `fallback-seam.conformance.test.ts` runs the same cases against both. Leaving
+ it behind would have made "both adapters agree" false in the suite that exists
+ to assert it.
+
+ Every converted body is judged by `scripts/check-route-envelope.mjs`, whose
+ per-file counters for these three modules go to zero and are banked as
+ conformant. The literals are deliberately written INLINE at each `c.json(...)`
+ call rather than hoisted into shared constants: the gate reads the object
+ literal, and an identifier reads to it as a relayed body it must not police —
+ hoisting would have zeroed the counters by hiding the bodies from the scanner
+ instead of by conforming them.
+
+### Patch Changes
+
+- 7337f30: 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.
+- 7ff3975: 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.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2d0af57]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/observability@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/plugins/plugin-hono-server/package.json b/packages/plugins/plugin-hono-server/package.json
index 9d5e68d484..33d7500aa7 100644
--- a/packages/plugins/plugin-hono-server/package.json
+++ b/packages/plugins/plugin-hono-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/plugin-hono-server",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Standard Hono Server Adapter for ObjectStack Runtime",
"main": "dist/index.js",
diff --git a/packages/plugins/plugin-pinyin-search/CHANGELOG.md b/packages/plugins/plugin-pinyin-search/CHANGELOG.md
index ac66c7e0d5..d3f5f6bbd8 100644
--- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md
+++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md
@@ -1,5 +1,73 @@
# @objectstack/plugin-pinyin-search
+## 17.1.0
+
+### Patch Changes
+
+- 7337f30: 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.
+- Updated dependencies [2f65b1b]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [e374b4d]
+- Updated dependencies [a751f7d]
+- Updated dependencies [eccb8b2]
+- Updated dependencies [650cd3d]
+- Updated dependencies [b735507]
+- Updated dependencies [91c6c28]
+- Updated dependencies [2d0af57]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [27a567d]
+- Updated dependencies [7ff3975]
+- Updated dependencies [24173e9]
+- Updated dependencies [f8eb736]
+- Updated dependencies [4e71ae1]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4dfa369]
+- Updated dependencies [5e2f594]
+- Updated dependencies [855591f]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [326f5de]
+- Updated dependencies [21995d7]
+- Updated dependencies [6a5e6ad]
+- Updated dependencies [b2a451f]
+- Updated dependencies [ff08691]
+- Updated dependencies [402c125]
+- Updated dependencies [7c2f386]
+- Updated dependencies [8a9e7f4]
+- Updated dependencies [3d0ded8]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [1258dca]
+- Updated dependencies [91c4ff5]
+- Updated dependencies [682b86b]
+- Updated dependencies [6a1b45e]
+ - @objectstack/types@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/objectql@17.1.0
+
## 17.0.0
### Patch Changes
diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json
index 255e7a4431..fa7c70108f 100644
--- a/packages/plugins/plugin-pinyin-search/package.json
+++ b/packages/plugins/plugin-pinyin-search/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/plugin-pinyin-search",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Pinyin search recall for ObjectStack — populates the hidden `__search` companion column (full pinyin + initials of the display/name field) so `$search` hits CJK names typed as pinyin. Locale-gated via OS_SEARCH_PINYIN_ENABLED (#2486).",
"main": "dist/index.js",
diff --git a/packages/plugins/plugin-reports/CHANGELOG.md b/packages/plugins/plugin-reports/CHANGELOG.md
index a8d9569943..7fba610d09 100644
--- a/packages/plugins/plugin-reports/CHANGELOG.md
+++ b/packages/plugins/plugin-reports/CHANGELOG.md
@@ -1,5 +1,108 @@
# @objectstack/plugin-reports
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/plugins/plugin-reports/package.json b/packages/plugins/plugin-reports/package.json
index a73e8610c4..5d936b5096 100644
--- a/packages/plugins/plugin-reports/package.json
+++ b/packages/plugins/plugin-reports/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/plugin-reports",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Saved reports + scheduled email digests for ObjectStack — sys_saved_report + sys_report_schedule + IReportService.",
"main": "dist/index.js",
diff --git a/packages/plugins/plugin-security/CHANGELOG.md b/packages/plugins/plugin-security/CHANGELOG.md
index 8ec80c6c51..561d53ac85 100644
--- a/packages/plugins/plugin-security/CHANGELOG.md
+++ b/packages/plugins/plugin-security/CHANGELOG.md
@@ -1,5 +1,874 @@
# @objectstack/plugin-security
+## 17.1.0
+
+### Minor Changes
+
+- 720ee95: fix(security): the shipped admin permission sets no longer grant export on the `*` wildcard (#8681)
+
+
+
+ **BREAKING for any deployment whose administrators export today.** Landing after
+ the v17.0.0 cut, so it ships as `minor` under the lockstep launch-window
+ convention; the migration prescription is registered under protocol major 18,
+ where `objectstack migrate meta` users will look.
+
+ `admin_full_access`, `organization_admin` and the derived
+ `organization_admin_no_bypass` shipped `objects['*'].allowExport = true`. That
+ single line made the 17.0 export axis **undeniable** for anyone holding an admin
+ set: an application could declare an object exportable by nobody, ship it, and
+ the platform would export it anyway.
+
+ Measured on 17.0.0 GA — 40 export probes, 5 principals, 8 objects, real Bearer
+ tokens — an org owner exported `crm_quote` (9 rows), `crm_campaign` (13) and
+ `crm_task` (15) with 200 and full data. No app permission set granted export on
+ any of the three, and the app had no way to say no:
+
+ 1. the wildcard lives in code-package metadata, so editing it answers
+ `403 [not_overridable] Metadata item 'permission/admin_full_access' is
+ provided by a code package`;
+ 2. the org admin holds no app-authored permission set, so there is nowhere to
+ author the per-object `allowExport: false` that would otherwise have won.
+
+ **This was never a gate defect.** The same run proves the export gate exact for
+ every other principal: a token refused on one object exports another on the same
+ route, granting `allowExport` at runtime flips 403 to 200, and revoking it flips
+ it back. A plain member carrying `'*': { allowExport: true }` exported too — the
+ wildcard was simply doing what it said. What changes is that the platform stops
+ shipping that grant.
+
+ This is #5491 applied to the export axis. That change removed `member_default`'s
+ CRUD wildcard because a wildcard in a set every principal resolves is not a
+ default but a floor no app can get under; the export wildcard survived by
+ omission rather than by decision, one tier up.
+
+ **Migration — grant `allowExport` explicitly in an app permission set where
+ admin export is intended.** There is no automatic replacement, deliberately:
+ which principals may take a bulk machine-readable copy of a table is the
+ segregation-of-duties judgement the axis exists to make explicit.
+
+ ```ts
+ // In YOUR app's permission set — not a platform set (those are not overridable).
+ {
+ name: 'system_admin',
+ objects: {
+ crm_account: { allowRead: true, allowExport: true }, // export intended
+ crm_quote: { allowRead: true }, // export withheld
+ },
+ }
+ ```
+
+ ⚠️ **Nothing fails at parse time, and the shipped sets are re-seeded on
+ upgrade.** A deployment that upgrades without editing anything is valid metadata
+ whose administrators have quietly lost export on every object no app set names —
+ the first sign is a support report, not an error. Verify behaviourally: sign in
+ as an org owner and call `GET /api/v1/data//export`, expecting 200 where
+ export is intended and 403 `EXPORT_NOT_PERMITTED` where it is not.
+
+ **What is deliberately unchanged.** READ is untouched — an admin still sees
+ every record they saw before; this narrows bulk egress only. `allowExport` on a
+ `'*'` entry remains a supported, honoured authoring shape in an app's own sets.
+ Specific-over-wildcard precedence is unchanged (an explicit per-object entry
+ still overrides the wildcard). The `viewAllRecords` / `modifyAllRecords`
+ super-user bits still do not imply export, exactly as before. And an app's own
+ admin set already gets precisely its declared posture — declared `false` answers
+ 403, declared `true` answers 200 — which is what makes withdrawing the platform
+ grant safe rather than merely restrictive.
+
+ Both admin sets are fixed together, and the org-admin pair from one declaration
+ (`organization_admin_no_bypass` is derived from `organization_admin`). Fixing
+ one and not the other was rejected outright: a half-closed export boundary reads
+ as closed and is not.
+- cc5c07b: fix(plugin-security)!: an insert that omits a required master-detail parent answers `400 VALIDATION_FAILED` with `fields[]`, not a `[Security]`-prefixed `422` (#8688)
+
+
+
+ **BREAKING (error contract).** On an `insert` into a `controlled_by_parent`
+ detail whose master reference is absent, the platform used to answer:
+
+ ```
+ HTTP 422
+ code : MISSING_REQUIRED_FIELD
+ error : [Security] Missing master reference: insert on 'crm_contact' did not
+ supply 'crm_account'. …
+ fields: (absent)
+ ```
+
+ It now answers the same envelope every other missing-required-field case
+ answers — `400 VALIDATION_FAILED`, carrying `fields[]` with
+ `{ field, code: 'required' }` — wherever required-field validation provably
+ refuses that omission. A client branching on `code === 'MISSING_REQUIRED_FIELD'`
+ for this condition must branch on `VALIDATION_FAILED` instead; a client already
+ handling the platform's ordinary missing-field envelope needs no change and
+ gains the field it could not previously highlight.
+
+ **What was wrong.** `assertControlledByParentWrite` runs in the security
+ middleware chain, *outside* the executor that calls `validateRecord`, so on an
+ insert it short-circuited required-field validation on the one field they
+ share. One user-visible condition therefore had two answers on adjacent
+ branches of the same field: absent → `422` with no `fields[]`, present but
+ unresolvable → `400 VALIDATION_FAILED` with `fields[]`. A form could highlight
+ the offending input in the second case and not the first, and any surface
+ rendering the message string showed a missing required field as a security
+ refusal. Measured live on 17.0.0 GA over REST.
+
+ The two harms could not be separated: both transport doors emit `fields[]` only
+ for the `VALIDATION_FAILED` duck-type and each overwrites `code` when it
+ matches, so "add `fields[]` while keeping `MISSING_REQUIRED_FIELD`" is not a
+ reachable throw shape.
+
+ **The stand-down is CONDITIONAL, and the residue is deliberate.** It applies
+ only where `validateRecord` really does refuse the omission: a `master_detail`
+ declared `required: true` and not `readonly`/`system`. For three other
+ declarable shapes — a `master_detail` with no `required`; `required` +
+ `readonly`; `required` + `system` — the validator skips the field before its
+ required check ever runs (`if (def.system || def.readonly) continue;`), so the
+ master gate is the only thing refusing the insert. There it keeps answering
+ `422 MISSING_REQUIRED_FIELD` exactly as before. A flat hand-over was measured to
+ mint a detail row with a null master FK, which the `controlled_by_parent` read
+ filter (`fk IN (readable masters)`) can never match — readable by nobody, and
+ answering `422` on every later by-id write.
+
+ **So the envelope asymmetry is not gone, it is confined** — to precisely those
+ three declarations, and no further. But confined is not unreachable: #8772
+ *proposes* a publish-time lint that would refuse them, and that issue is open
+ and unruled, so nothing refuses them at publish today. A `master_detail` with
+ no `required` draws only a non-blocking `warning`; `required` + `readonly` and
+ `required` + `system` draw nothing at all. An app can therefore newly declare
+ any of the three, publish cleanly, and still see the old
+ `422 MISSING_REQUIRED_FIELD` with no `fields[]` — so treat these shapes as a
+ live surface to avoid authoring into, not as a legacy tail that is already
+ closing. One further residual, narrower still: a
+ `controlled_by_parent` object whose relation resolves through the required-*lookup*
+ fallback also keeps the `422` — validation would cover it, but the ruling covers
+ `master_detail`, and widening a ruling is not the implementer's call.
+
+ **Unchanged, and pinned as unchanged:** a master that is *present but not
+ writable* by the caller still answers `403 PERMISSION_DENIED — requires edit
+ access to its master record`. The stand-down is keyed on the FK being absent;
+ every access leg still runs when one is supplied. The stored-row shape (a by-id
+ write whose persisted FK is null) also keeps its `422`: the caller sent no such
+ field, so a `fields[]` naming it would name a field that was never in the
+ request, and no payload the caller could send would fix it.
+
+ **One pin was rewritten deliberately**, not adjusted to match new behaviour: the
+ `[#7474]` six-envelope truth table's **insert** leg in
+ `controlled-by-parent-sharing.test.ts`. Its successor asserts both sides of the
+ condition — the covered shape hands over (the executor is reached, and the real
+ `validateRecord` refuses with `VALIDATION_FAILED` + `fields[]`), and each
+ uncovered shape still gets the `422` (with the real validator raising nothing on
+ the same payload, which is why the gate must stay). The truth table's other
+ legs are update-path and are untouched.
+
+ This supersedes the 2026-08-11 envelope choice on #7474, on that ruling's own
+ rationale: if a detail without its master is "precisely a required value that is
+ absent", the platform's contract for a required value that is absent is
+ `400 VALIDATION_FAILED` with `fields[]`.
+- 6feac91: **Security boundary change — this WIDENS who may write rows that are refused today.** On an ADR-0055 `controlled_by_parent` detail, the ADR-0055 master gate is now the sole row-level write authority: the platform's wildcard ownership floor (`owner_only_writes` / `owner_only_deletes`, `created_by == current_user.id`) is no longer applied to such a detail at the by-id write pre-image gate. A by-id UPDATE or DELETE of a child row **created by another user** now succeeds whenever the caller may edit that child's master — where it previously answered `403` `record_access_denied`. Maintainer ruling 2026-08-15 on #8757 (delegated adjudication).
+
+ What the widening rests on: `assertControlledByParentWrite` — the object's declared write gate — already runs on the same operation, immediately after the pre-image gate, under a superset of its guard, and it refuses whenever the master is not editable. The floor is handed to that gate, not removed. Callers who could not edit the master are refused exactly as before, with the master gate's own sentence instead of the record-access one.
+
+ Why it was wrong before: `controlled_by_parent` means "access derives from the master", and the detail declares nothing about who may write it. Two gates were answering one write, and the stricter — a creator-only rule no author wrote — always won: `SharingService.checkEdit` abstains on the `public`-mapped model before reaching its `modifyAllRecords` branch, so ownership depth, an `edit`-level `sys_record_share` and Modify All Data were all inert on a detail.
+
+ Deliberately unchanged, each measured:
+
+ - **BULK (AST) writes keep the floor.** `assertControlledByParentWrite` returns early with no single id, so nothing would replace it there. The floor is dropped from the by-id call site, never from the object's posture alone.
+ - **Delegated (on-behalf-of) by-id writes keep both principals' floors**, matching ADR-0090 D10's existing exclusion at this gate.
+ - **INSERT and the read path are untouched** — an insert has no pre-image and so never carried the floor; the floor is `update`/`delete`-only.
+ - **App-authored policies are untouched** (provenance, ADR-0105 D3), Layer 0's tenant wall is untouched, and a detail that authors its own `select` policies still derives its write scope from them (#7665).
+- 5f5e234: fix(security): `sys_permission_set.active` and `sys_position.active` now actually stop granting access (#8613)
+
+
+
+ **BREAKING for deployments that already switched a permission set or position
+ off.** Both objects ship a Deactivate action whose confirmation dialog promises,
+ in all four locales, that access stops:
+
+ > Deactivate this permission set? Existing assignments stay in place but stop
+ > granting access until re-activated.
+ > Deactivate this position? Users keep their assignment but the position stops
+ > granting permissions until re-activated.
+
+ Nothing read the column. Measured on the real resolver: a position seeded
+ `active: false` still granted its permission sets, and a permission set seeded
+ `active: false` still returned `posture: PLATFORM_ADMIN` with its system
+ permissions. Deactivation moved a badge in Setup and nothing else — while the
+ admin who had just revoked a compromised or over-broad grant was told the
+ opposite, and whose likely next step was therefore *not* the action that would
+ have worked (delete the set, or remove the assignments).
+
+ **What changes at runtime.** `resolveAuthzContext` / `resolveUserAuthzGrants`
+ (`@objectstack/core`) — the single seam every transport resolves authorization
+ through — now drop a deactivated row **before** any derivation:
+
+ - a deactivated `sys_position` no longer contributes its
+ `sys_position_permission_set` grants, and its name leaves `positions` (so the
+ name-reuse path cannot resolve the same grant one layer down);
+ - a deactivated `sys_permission_set` contributes no name, no
+ `system_permissions`, no `tab_permissions`, **and no `PLATFORM_ADMIN`
+ posture** — the flag is applied before the posture is derived, not after;
+ - the `plugin-security` DB loader applies the same predicate, which is what
+ judges a set reached by NAME through an active position of the same name.
+
+ Both tables were already read at that seam, so this costs **zero new hot-path
+ queries**.
+
+ **⚠️ Read this before upgrading.** Any `sys_permission_set` or `sys_position`
+ row currently carrying `active: false` **stops granting the moment this
+ lands** — on live data, with no migration step to notice. That is the correct
+ direction (it is what the dialog said when someone clicked Deactivate), but on
+ an installation that used the switch believing it was inert it is a real
+ revocation. Before upgrading, list the deactivated rows and re-activate any that
+ are still meant to grant:
+
+ ```
+ GET /api/v1/data/sys_permission_set?filters=[["active","=",false]]
+ GET /api/v1/data/sys_position?filters=[["active","=",false]]
+ ```
+
+ A row whose `active` column is **absent or NULL** is unaffected: the predicate
+ is "explicitly deactivated", never "explicitly active", so rows that predate the
+ column keep granting exactly as before.
+
+ **Break-glass, closed in the same change** (`@objectstack/plugin-auth`).
+ Enforcing the flag opened a one-click, installation-wide lockout: deactivating
+ `admin_full_access` un-makes every platform admin at once, through a payload
+ that touches neither `name` nor any identity table, and re-activating requires
+ the permission the click just took away (the seeders deliberately never
+ reconcile `active`, so no restart restores it). The last-administrator guard now
+ judges that write like the delete and rename spellings it already refused, and
+ an environment whose break-glass set is *already* off is read as emptied rather
+ than as a bootstrap window — so it does not silently disarm the guard for every
+ other identity write. Re-activation itself stays permitted, or the refusal would
+ have no way out from inside the product.
+- 0ccea4a: 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.
+- 3851f87: Partial field masking (#8993): `FieldSchema` declares `maskingRule` — a closed
+ preset enum (`phone`, `id_card`, `bank_account`, `email`, `name`) plus a
+ `{ keepHead, keepTail }` escape hatch — and plugin-security's `FieldMasker`
+ enforces it in the same PR (ADR-0049 declare = enforce; the key re-enters the
+ schema only with its runtime consumer attached, honouring the 2026-06 prune in
+ spirit).
+
+ A field declaring a rule is served masked-but-recognisable (`138****5678`) to
+ every non-system caller; the field's `requiredPermissions` (ADR-0066 D3) is the
+ unmask gate — holders of all listed capabilities read the full value. A
+ permission set that marks the field non-readable still deletes it entirely.
+ Masking rides the single runtime channel, so API callers, browser users, the
+ CSV/XLSX export route and the AI-context interceptor all see the same
+ deterministic, length-preserving masked value. Masked callers cannot filter,
+ sort, group or aggregate on the field (403, the FLS predicate-oracle guard),
+ and a write that round-trips a masked placeholder is refused with
+ `400 VALIDATION_ERROR` instead of silently overwriting the stored value.
+ New exports: `FieldMaskingRuleSchema`, `FieldMaskingKeepSchema`,
+ `FIELD_MASKING_PRESETS`, `maskFieldValue`, `MASK_CHAR`.
+
+### Patch Changes
+
+- cf0d902: fix(security): the `controlled_by_parent` master-editability check consults the same app-authored write widener the by-id path does (#8679)
+
+
+
+ `crm_campaign_member`-shaped objects — ADR-0055 `controlled_by_parent` details —
+ route every insert/update/delete through `assertControlledByParentWrite`, which
+ asks whether the caller may EDIT the master. That gate's record-sharing leg
+ hard-refused on `canEdit === false` **without ever asking whether an app-authored
+ RLS update-widener admits the master row**. The by-id write path has asked
+ exactly that since #5493 (merged as PR #6909), where the deferral was installed
+ on the sharing middleware's refusal branch.
+
+ So one principal, one master record and one operation got **two different
+ answers depending on who was asking** — measured on 17.0.0 GA with real Bearer
+ tokens, one variable (who created the master), everything else identical:
+
+ | step | master created by ADMIN | master created by the caller |
+ |---|---|---|
+ | PATCH the master itself, by id | **200** | 200 |
+ | INSERT a child | **403** | 201 |
+ | UPDATE a child | **403** | 200 |
+ | `security/explain` update on the master, record-scoped | **`allowed=true`** | `allowed=true` |
+
+ The master write and the platform's own `explain` verdict both said yes; only the
+ derived write disagreed, refusing with `master '...' not editable by this user
+ (record sharing)` — naming the very layer #6909 had already taught to defer.
+
+ **The fix consults the same composition, and does not relax the check.** The
+ verdict comes from `checkAuthoredRowWrite` — the method
+ `SharingService.probeAuthoredRowWrite` passes straight through to — so the answer
+ at this call site is byte-for-byte the one a direct by-id write of that master
+ would get. There is no second copy to drift, which matters because a duplicated
+ permission composition is how the two paths diverged. The question is asked for
+ `update`, matching the two legs already above it: this gate's subject is edit
+ access to the master, never the detail's own verb.
+
+ Nothing else widens. The object-level `update` grant and the master's own
+ write-RLS leg run first and still refuse on their own terms; `admit` retracts
+ only the record-sharing leg's refusal, exactly as an `admit` on the by-id path
+ hands the row to the pre-image gate rather than authorizing anything. Every other
+ outcome — `abstain`, no authored policy, a `check`-only policy, a principal-less
+ or delegated context, a throwing probe — leaves the refusal untouched, and the
+ method is fail-closed in the `abstain` direction, so no failure mode here can
+ open access.
+
+ The regression proof drives both directions on one fixture and refuses to be
+ satisfiable by a relaxation: the RLS-widened master **permits** the derived write
+ **and** a principal with no widener and no share is still refused on the same
+ route with the same payload. A transferred master (write RLS admits via the
+ platform floor, record sharing refuses because the owner is someone else) keeps
+ the record-sharing leg itself pinned live — deleting that leg outright would
+ otherwise leave the suite green — with an `edit`-level share admitting the same
+ row and a `read`-level share still refusing it.
+- 498f4e8: fix(security): `controlled_by_parent` detail writes compose the master's ownership floor the same way a direct write does (#8865)
+
+ **This change widens a permission boundary, deliberately and with maintainer
+ approval (ruling of 2026-08-15, direction 1): children of a master become
+ writable by every principal whose record-sharing verdict on that master is
+ `allow`.** That is the same set which already reaches the master itself — the
+ widening restores a symmetry the platform declares, it does not mint a new
+ capability — but it is a real widening and it is stated here rather than
+ softened.
+
+ ## What was measured
+
+ `assertControlledByParentWrite` (ADR-0055, step 2.8) resolves master-edit access
+ in two legs. Leg 1 — the master's own write RLS — computed
+ `computeRlsFilter(master, 'update')` with **no** `dropPlatformOwnershipFloor`,
+ while the by-id write pre-image gate (step 2.7) computes the same filter for the
+ same object with that knob set whenever `ISharingService` answers `allow`. So the
+ platform's ownership floor (`created_by == current_user.id`, shipped by
+ `member_default`) was dropped on the direct path and left standing on the derived
+ one, and one principal, one master row and one `update` got two answers:
+
+ | step | verdict before |
+ |---|---|
+ | PATCH the master `camp_mkt` directly, by id | allowed |
+ | UPDATE a child of `camp_mkt` | `403 … requires edit access to its master record (master 'crm_campaign' not editable by this user (row-level security))` |
+
+ The principal in that measurement holds `modifyAllRecords` on the master, which
+ is exactly what makes the sharing verdict `allow` and drops the floor on the
+ direct path; it did not create the master, so the undropped floor refused it on
+ the derived path. Every widening mechanism the platform declares — ownership at
+ write DEPTH, an `edit`-level `sys_record_share`, `modifyAllRecords` — was
+ therefore inert **for children** while it worked **for the master itself**. An
+ app author saw a master they could edit and children they could not.
+
+ This is the divergence #8679 closed in leg 2 (record sharing), surviving one leg
+ over, and it is closed the same way: one principal, one row, one operation must
+ not get two answers.
+
+ ## The change
+
+ Leg 1 adopts step 2.7's composition, clause for clause:
+
+ - ask `resolveSharingWriteVerdict('update', master, masterId, …)` — the tri-state
+ verdict, not `canEdit`'s boolean projection — and drop the platform ownership
+ floor **only** on `allow`;
+ - ask it only when a platform floor policy is actually applicable to this
+ (principal, master, `update`), so an object with no floor in play spends no
+ sharing probe;
+ - `abstain` and `deny` both leave the floor standing, and the verdict answers
+ `deny` when its own probe throws, so no failure mode of this composition can
+ widen;
+ - the on-behalf-of path (ADR-0090 D10) is excluded, mirroring step 2.7: a
+ delegated write keeps **both** principals' floors, exactly as before.
+
+ Only the PLATFORM's floor is droppable (provenance, ADR-0105 D3). An app-authored
+ policy — including one spelling the identical predicate — reaches the compiler
+ untouched and still refuses (ADR-0049), and Layer 0 (the tenant wall) is not
+ affected at all. Step 2.7's composition and the insert leg's #8688 stand-down are
+ untouched.
+
+ ## Pinned
+
+ The residual assertion the measuring run left in the tree
+ (`controlled-by-parent-detail-write-authority.test.ts`, labelled `RESIDUAL
+ (#8865)` with the comment "When #8865 lands the assertion above flips") now
+ asserts the permission, and keeps its witness — the same principal, the same
+ master row, the same operation, asked directly — so the two paths cannot drift
+ apart again without a red.
+
+ A new section pins the flip to the composition rather than to a relaxation, each
+ case varying one input and asserting the direct write of the master agrees:
+
+ - an `edit`-level `sys_record_share` on the master — and nothing else — is what
+ moves a child write from refused to permitted;
+ - an owner-less master, where `checkEdit` abstains for everyone (Modify All Data
+ included), keeps its floor and refuses on both paths — the case that separates
+ the ruled `=== 'allow'` composition from the boolean projection;
+ - an app-authored master policy still refuses a principal whose sharing verdict
+ is `allow`, while the same write without that policy is permitted.
+- 4c178c1: 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.
+- 8656d67: fix(plugin-security): the derived capability seeder's skip is counted and warned instead of leaving the platform bucket silently unseeded (#8536)
+
+ **This does not change what the seeder does. It changes whether an operator can
+ tell what it did.** No adoption, no backfill, no new writes — the #5876 guard
+ keeps declining an authored row, which is the ruled behaviour (#8552 settled the
+ posture on an occupied platform bucket: keep declining, loudly).
+
+ `bootstrapSystemCapabilities` derives a placeholder `sys_capability` row for any
+ capability a bootstrap permission set grants by name. Its lookup runs under the
+ system context, which carries no `tenantId`, so it reads **across
+ organizations** — and when the row it finds is one it does not own, the #5876
+ guard `continue`s before any insert is attempted.
+
+ Before #8461 that was harmless, because `name` was unique installation-wide: "a
+ row resolves this name" and "the platform holds a row for this name" were one
+ statement, which is exactly what #5876's reasoning rests on ("the capability
+ resolves and the authored copy is the better one"). Per-organization uniqueness
+ (ADR-0120 D1) separated them. An organization's row now satisfies the lookup
+ while the platform's NULL-organization bucket is **never written at all**, and
+ nothing said so: `skippedAuthored` moved, and that counter cannot distinguish
+ "an authored copy was left alone" from "the platform's definition exists
+ nowhere".
+
+ The skip now reads the platform bucket once — on that branch only, the same cost
+ the curated half already accepted — and warns with the curated half's
+ provenance-naming shape: it names the `managed_by` and organization it **read**
+ off the blocking row rather than asserting an ownership verdict, states which of
+ the three bucket observations it saw (free / held by an unstamped row / held by
+ a row with a named provenance), and carries the #8552 hand-resolution line only
+ where a row an operator may legitimately rename is what blocks the bucket. Where
+ an organization's row is what stands in the way, the message says there is
+ nothing to remove — that row is a supported ADR-0066 D1 extension.
+
+ The warning fires only where the platform's own placeholder is genuinely
+ **absent**, so it means one thing. A skip that declines a mere refresh — the
+ placeholder is present and simply was not the row the cross-organization lookup
+ selected — stays summary-only, as #4632 decided.
+
+ `CapabilitySeedResult` gains `unseededDerived`, a documented **subset** of
+ `skippedAuthored` rather than a split of it: the existing counter keeps its
+ meaning and its value, because the two facts are separable only since #8461 and
+ neither should be inferred from the other.
+- e9534a4: 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.
+- 4ea921c: Repair the ADR-0090 `sys_role` → `sys_position` rename in the es-ES object
+ translation bundles, and guard it mechanically.
+
+ The rename half-landed in Spanish: an unreviewed substring find-replace produced
+ two non-words (`Puestoes` as the plural of `Puesto`, and `contpuesto` where the
+ replace ate the unrelated word `control`), while nine further leaves in
+ `plugin-security` and three in `plugin-sharing` were missed entirely and still
+ named the pre-rename concept. In `plugin-sharing` the same picklist key rendered
+ two different ways in one file — `position` was `Puesto` on the sharing rule and
+ `posición` on the record share, and `unit_and_subordinates` read `Rol y
+ subordinados` (naming the removed role concept) against `Unidad de negocio y
+ subordinados` on its sibling.
+
+ Spanish-facing admins saw `Puestoes` as the object's plural label in navigation
+ and list views, and two different words for one recipient kind across two Setup
+ screens.
+
+ Two regression guards now cover the classes involved: a malformed-compound and
+ stale-term check on the renamed security objects, and a self-consistency check
+ asserting that a picklist option key shared by several sharing objects renders
+ identically within a locale. Neither needs a reader of the locale to review it.
+- c73eacd: Reconcile audience-binding suggestions per organization (ADR-0090 D5/D9)
+
+ `sys_audience_binding_suggestion` rows are per-tenant by construction — a
+ package suggests, and a TENANT admin confirms — but the reconciler read and
+ wrote through a module-level `{ isSystem: true }` context carrying no tenant.
+ On a shared-runtime multi-organization installation that produced ONE
+ organization-less row that every tenant read: the first admin to confirm or
+ dismiss answered for all of them, while the binding their confirm created
+ existed only in their own organization, so every other tenant's users never
+ received the package's default permission set and the surface reported the
+ suggestion resolved.
+
+ - every read and write in the module now carries `{ isSystem: true, tenantId }`
+ — the anchor lookup, the "is it already bound?" lookup, and the
+ list/confirm/dismiss paths, not just the writes;
+ - `reconcileAudienceBindingSuggestions` is the new entry point the runtime
+ calls: one pass per organization under a `group`/`isolated` posture, and the
+ publishing organization alone on the package-door publish path;
+ - pre-existing organization-less rows are reaped before the passes and
+ regenerated per organization. Without that, ADR-0120 D3's platform bucket
+ keeps showing the old row to every tenant and the per-organization passes
+ create nothing at all. No permission binding is touched by the reap.
+
+ A `single`-posture deployment is unchanged: exactly one organization-less pass,
+ and no reap.
+- 712e185: fix(security): platform default permission sets are stamped `managed_by: 'platform'`, so `os meta resync` stops skipping every one of them (#8692)
+
+
+
+ `bootstrapPlatformAdmin` seeded the default permission sets
+ (`admin_full_access` / `member_default` / `viewer_readonly` …) **without writing
+ `managed_by`**, so the value fell to the declared `defaultValue: 'admin'` on
+ `sys_permission_set`. `os meta resync` only reconciles rows the platform still
+ owns (`managed_by` absent or `'platform'`), so the platform's own default sets
+ took the skip branch — **measured on a real engine: `resynced 0` /
+ `resyncSkipped 8`, every shipped set**, each one logged as an *"intentional
+ override"* for a row no admin had ever touched.
+
+ That is the exact inverse of what the resync flag was built for (#2705:
+ *"reconcile the row to the shipped dist so a dev source edit takes effect
+ without `--fresh`"*). The command could not perform, for the rows it names in
+ its own help text, the one job it exists to do.
+
+ **The seed insert now stamps `managed_by: 'platform'` explicitly**, which also
+ puts this seeder in line with its two siblings in the same package —
+ `bootstrap-builtin-positions.ts` and `bootstrap-system-capabilities.ts` both
+ stamp `'platform'` rather than inheriting a default. A fresh install's default
+ sets are now platform-owned, and a resync reconciles all of them. Admin-takeover
+ protection is unchanged in shape and becomes *real* rather than nominal: a set
+ an admin takes over in Setup is stamped `'admin'` by the projection path, so
+ platform-seeded and admin-authored rows finally carry **different** values
+ instead of the same one.
+
+ **Forward-stamp only — existing rows are deliberately NOT migrated.** A stored
+ `'admin'` is indistinguishable between "the old seeder's field default" and "an
+ administrator took this set over in Setup". Restamping legacy rows to
+ `'platform'` would make genuine admin customizations reconcilable and could
+ silently overwrite them on the next `os meta resync`, so pre-existing rows keep
+ the skip permanently and by decision. Report, don't rewrite. A legacy install
+ that wants its platform defaults reconciled has to re-own the rows deliberately
+ (or re-seed with `--fresh`) — an operator's choice, not one a boot makes for
+ them. The seeder's docblock records this so the next reader finds a decision
+ rather than a mystery.
+
+ **The skip warning stops claiming intent.** It read
+ `… row is admin-owned (intentional override)`; on any pre-existing install that
+ sentence is false, because the only writer may have been this same seeder one
+ call earlier. It now reads `… row is admin-owned` — provenance and action, no
+ claim about anybody's intent.
+
+ Two comments asserting that the insert-once posture *"keeps the platform
+ defaults env-authored — the posture `bootstrapDeclaredPermissions` relies on"*
+ are removed: that reliance was measured false. `bootstrapDeclaredPermissions`
+ special-cases only `managed_by === 'package'`; every other value — `'platform'`
+ included — falls to the same `skippedEnvAuthored` branch, so its behaviour is
+ identical before and after this change.
+
+ The pin suite added by the measurement round now asserts both sides of the line
+ the ruling drew: a fresh install stores `'platform'` and resyncs everything, and
+ a pre-ruling `'admin'` row is still skipped with its content intact.
+- 693c788: fix(security): the derived capability seeder owns its row by the same conjunction as the curated half
+
+ `bootstrapSystemCapabilities`' DERIVED half tested ownership with `managed_by === 'platform'` alone. That was sufficient while `sys_capability.name` was unique installation-wide; since #8461 made it unique per ORGANIZATION (ADR-0120 D1) it also admits a platform-STAMPED row sitting inside an organization — the shape the file header names ("from seed data or a legacy import") and the shape #8470 refused to let `managed_by` alone stand for on the curated half, because it "would not carry that guarantee". The guard admitted such a row and rewrote its `label`/`description` with `humanize(name)`, which is the precise harm #5876 exists to prevent, while the platform (NULL-organization) bucket was never written. Every counter read zero and nothing was logged, because both #5876's counter and #8536's live on the branch where the guard DECLINES.
+
+ The ownership test is now the same conjunction the curated half uses — `managed_by: 'platform'` AND `organization_id: null`. The lookup is unchanged (still cross-organization, by design). This restores a declared invariant rather than widening an accept set: what the derived half may refresh narrows to the rows it provably owns.
+
+ **Reachability: a DORMANT asymmetry with a LIVE route — not a live defect.** No shipped artifact in this repository produces such a row: both capability seeders run under a system context with no tenant and never write `organization_id`, `normalizeManagedByVocab` does not touch this object, the admin door refuses the stamp outright (`assertSystemRowWriteGate`), and no `sys_capability` seed dataset exists anywhere in the repo. The ROUTE is nevertheless live and needs no unsupported step, and its load-bearing link is measured rather than argued: the seed loader writes as `isSystem` specifically so seeds can target `sys_*` tables, `defineSeed` type-checks `managed_by: 'platform'`, and on a per-organization replay the loader's tenant stamp short-circuits its own `sys_` exemption when an organization is pinned. Measured against the real seed loader, a `sys_capability` seed carrying `managed_by: 'platform'` was inserted with `organization_id` set when an organization was pinned, and inserted unstamped when none was — so the stamp is the pinning's doing, not a fixture artifact. Not claimed: how many organizations a given deployment replays seeds into is a provisioning question this repo cannot answer. So the fix lands as trap-removal and invariant-restoration, at exactly that severity — worth landing because the mistake would be invisible, ADR-0066 asset ownership forbidding the organization's own admin from editing or deleting the row through Setup.
+
+ **Observability.** The newly-declined row flows through #8536's skip branch unchanged, so `skippedAuthored` and `unseededDerived` keep their exact documented meanings and their subset relationship; they simply become reachable on a state the broken guard used to swallow. The misplaced stamp gets its OWN signal, a new `platformStampedInOrg` counter on `CapabilitySeedResult`, rather than being folded into `unseededDerived` — "the platform's definition is missing" and "a row wears the platform's stamp where the platform never writes" are different facts, and the second is worth counting even when the first is false. The warning gains a matching remediation arm; the admin-authored row's "supported extension" sentence would be false here, and its "nothing for an operator to remove" advice would be wrong about the one row Setup cannot touch at all.
+
+ **Not changed:** the platform bucket is still not backfilled when another row satisfies the lookup. That is #8552's ruled posture (no adoption, no backfill), shipped for the admin-authored case in #8536; the fix makes the state observable, not repaired, and the suite pins the bucket ABSENT so a future backfill has to fail rather than pass.
+
+ `patch`, not `minor`: the behaviour change is a guard declining a row it should never have rewritten, plus diagnostics. `platformStampedInOrg` is a new field on a returned result object, but `bootstrapSystemCapabilities` is a boot-time internal whose only caller ignores the result shape — no consumer reads the type, so nothing gains a capability it can build on.
+- c25b2d5: fix(security): comment moderation stops being dead behind the platform delete floor — `sys_comment` gets the per-object delete policy that lets a parent-record editor moderate (#8839)
+
+
+
+ `plugin-audit` implements an explicit **author-or-parent-editor** rule for
+ removing a comment — *"Rewriting or removing someone else's words is moderation,
+ hence the tighter author-or-parent-editor rule"* — deriving a comment's access
+ from the record its `thread_id` names, the way an attachment's derives from its
+ parent.
+
+ **That rule was unreachable in every org-bound deployment.** `member_default`
+ ships a wildcard row-level delete floor:
+
+ ```
+ { name: 'owner_only_deletes', object: '*', operation: 'delete',
+ using: 'created_by == current_user.id', positions: ['org_member'] }
+ ```
+
+ A parent-record editor moderating someone else's comment holds `org_member` and
+ is not the comment's `created_by`, so the floor answered `PERMISSION_DENIED`
+ before the moderation rule was ever consulted. The floor is a **second,
+ parent-blind implementation** of "who may remove this row", and on `sys_comment`
+ it was winning against the one authority that can actually see the parent.
+
+ **Why nothing caught it:** the only fixture proving the capability
+ (`comments-permission-matrix.dogfood.test.ts` case (d)) booted **org-less**, so
+ its principals resolved `positions: ['everyone']`, the positions-gated floor never
+ applied, and the case passed over the broken behaviour — #8023's disarm shape.
+
+ **The fix is one per-object policy** in `member_default`:
+
+ ```
+ { name: 'sys_comment_moderation', object: 'sys_comment', operation: 'delete',
+ using: 'id != null', positions: ['org_member'] }
+ ```
+
+ It contributes the **alternate match** that stops the floor pre-empting the gate;
+ it does not re-implement the rule. The parent-editor limb is not expressible as a
+ row predicate — the authority lives on another record and RLS has no join — so
+ `id != null` is every row of this object said plainly, the same spelling and
+ reasoning as the existing `sys_invitation_org_admin`. What actually narrows a
+ `sys_comment` delete is, in order: the object-level delete bit (this set grants no
+ `allowDelete` at all), Layer 0's tenant wall, and then plugin-audit's gate, which
+ requires every matched row to pass and fails closed on a thread naming no
+ authorizable parent. That gate is not optional — `AuditPlugin` registers
+ `sys_comment` and installs the gate in the same `start()`.
+
+ ⛔ **The wildcard floor itself is unchanged.** The widening is scoped to
+ `sys_comment`, and to the `delete` limb only; the `update` half of plugin-audit's
+ rule deliberately stays under the floor.
+
+ The `positions: ['org_member']` domain is load-bearing rather than cosmetic: it
+ confines the widening to exactly the principals the floor binds. An undomained
+ twin would carry a `using` into a delete class that is **empty** today for
+ org-less and `everyone`-only principals, switching off the derive-from-select rule
+ that currently bounds their writes to their readable set — widening them too.
+
+ Access-widening approved by maintainer ruling (2026-08-15), which is what the
+ standing manual floor on relaxing an access-control boundary required.
+
+ The pin is the fixture, now **armed**: `orgContext: true` plus `assertArmed` on
+ both the author and the moderator persona, so the file can never again certify
+ moderation from a boot structurally unable to observe the floor. Reverse-verified
+ — with the policy removed and the artifact rebuilt, exactly one case reddens with
+ `PERMISSION_DENIED` on `sys_comment` and the other nine stay green. The
+ stranger-without-parent-EDIT case now asserts its refusal code **exactly**
+ (`RECORD_NOT_ACCESSIBLE`, plugin-audit's gate — not the floor's
+ `PERMISSION_DENIED`), so the floor silently re-asserting itself over `sys_comment`
+ cannot pass as a correct refusal.
+- 147eadc: Correct `sys_position`'s translated uniqueness text in the `es-ES`, `ja-JP` and `zh-CN` bundles to say the machine name is unique **per organization**
+
+ The English bundle and the object source both already state that a position's machine name is unique per organization — the declared index is `{ fields: ['name'], unique: 'organization' }`. The three other shipped locales still asserted bare, unqualified uniqueness, so an admin reading Setup in Spanish, Japanese or Chinese was told the name had to be free installation-wide, which the declared index does not enforce.
+
+ Both places `sys_position` states the rule are corrected:
+
+ - `fields.name.help`, the field help in the object's detail and edit views. It now also carries the source's current examples (`sales_manager`, `hr_specialist` rather than the superseded `admin`, `editor`, `viewer`).
+ - `actions.clone_position.params.name.helpText`, the help on the Clone Position dialog's API-name input — the text an admin reads at the moment they type a new name.
+
+ Leaf string values only — no bundle structure was hand-edited.
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [b6c7690]
+- Updated dependencies [3851f87]
+- Updated dependencies [845e164]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [7fc01db]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/metadata-core@17.1.0
+ - @objectstack/formula@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json
index 15a4b3b423..93d91ab6b9 100644
--- a/packages/plugins/plugin-security/package.json
+++ b/packages/plugins/plugin-security/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/plugin-security",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Security Plugin for ObjectStack — RBAC, RLS, and Field-Level Security Runtime",
"main": "dist/index.js",
diff --git a/packages/plugins/plugin-sharing/CHANGELOG.md b/packages/plugins/plugin-sharing/CHANGELOG.md
index 81ce749e89..ac96ae1e95 100644
--- a/packages/plugins/plugin-sharing/CHANGELOG.md
+++ b/packages/plugins/plugin-sharing/CHANGELOG.md
@@ -1,5 +1,364 @@
# @objectstack/plugin-sharing
+## 17.1.0
+
+### Minor Changes
+
+- 04d03c3: fix(security): a deactivated `sys_position` stops conferring sharing-rule record shares (#8710)
+
+
+
+ **BREAKING for deployments that already deactivated a position named as a
+ sharing-rule recipient.** Their `sys_record_share` rows are revoked on the next
+ evaluation of that rule.
+
+ #8613 made `sys_position.active` real at the authorization DERIVATION seam: a
+ deactivated position stops carrying its permission sets and its name leaves
+ `context.positions`. A sharing rule reaches users by a **second road that never
+ passes that seam** — `SharingRuleService.expandRecipient` → `PositionGraphService`
+ — so a rule sharing records with `cfo` kept sharing them after `cfo` was
+ deactivated, while the `deactivate_position` dialog promises, unqualified:
+
+ > Deactivate this position? Users keep their assignment but the position stops
+ > granting permissions until re-activated.
+
+ A record share is access, so the promise covers it. Maintainer ruling,
+ 2026-08-15, verbatim: **"Access-conferring paths filter deactivated positions;
+ addressing paths do not."**
+
+ **What changes at runtime.** When a sharing rule's recipient is a position, the
+ evaluator reads the `sys_position` catalogue row and, if it is explicitly
+ deactivated, the rule expands to **nobody**:
+
+ - no new shares are materialised for that position's holders;
+ - the shares it had already materialised are **revoked** on the next
+ reconcile — by `evaluateRule`, by the per-record hook pass, and by the
+ synchronous recipient-axis revoke (#7729) — because a rule that confers
+ nothing has an empty desired set and every existing grant is stale;
+ - the verdict is read with `isRowActive` (`@objectstack/core`), the same
+ predicate #8613 established, so the 1/0 and `'false'` storage shapes every
+ driver produces are judged identically.
+
+ This is **not** a refactor and **not** a no-op: it changes who receives record
+ shares.
+
+ **What deliberately does NOT change**, per the same ruling:
+
+ - **approval ROUTING** keeps reading the raw directory — filtering there is
+ fail-OPEN (an approval step routing to nobody), #8613's carve-out, reaffirmed;
+ - **write gates and blast-radius reads** (`assertAudienceAnchorBindingGate`,
+ `setsBoundToPosition`, the delegated-admin surfaces) stay unfiltered, because
+ dropping a deactivated row there would make a refused binding permitted and
+ narrow a delegate's boundary — access *widening*;
+ - `PositionGraphService.expandPositionUsers`, the ADDRESSING primitive, is
+ untouched: the filter is at the sharing call site, so moving it down into the
+ helper would take the paths above with it. A pin fails if it ever does.
+
+ **Rows that keep granting exactly as before:** a position whose `active` column
+ is absent or NULL (the predicate is "explicitly deactivated", never "explicitly
+ active"), a recipient name with no `sys_position` row at all (the
+ `sys_member.role` transition source of ADR-0057 D4), and a position whose
+ same-name row was deactivated in *another* organization — `sys_position.name` is
+ unique per organization (#8468), so the flag is read off this rule's own
+ tenant's row.
+
+ **Cost.** One `sys_position` read per distinct position per evaluator pass,
+ memoised for that pass only (a memo outliving the pass would make a deactivation
+ take effect late). The ruling accepted the extra read explicitly; the sibling
+ seam in #8613 needed none because both tables were already at hand there.
+
+ **Before upgrading**, list the deactivated positions and check whether any is a
+ sharing-rule recipient whose shares are still meant to flow — re-activate those,
+ or move the grant onto the rule:
+
+ ```
+ GET /api/v1/data/sys_position?filters=[["active","=",false]]
+ GET /api/v1/data/sys_sharing_rule?filters=[["recipient_type","=","position"]]
+ ```
+- f8537df: A write refused because a federated object's `owner_id` is the platform's phantom anchor now says so, once per object (#8418).
+
+ **No verdict changes.** `checkEdit` / `checkDelete` stay fail-closed exactly as shipped — this adds a diagnostic and nothing else. Maintainer ruling 2026-08-13 (option C on #8418): keep `deny`, make the refusal visible.
+
+ What was wrong: on an ADR-0015 federated object with no author-declared `owner_id`, the registry injects the anchor but the platform provisions no column behind it, so the ownership fast path selects `owner_id` off a remote table that does not have it. The SQL driver's recovery ladder DISCARDS a projection naming an unresolvable column and re-runs `select('*')` instead of raising — so `matchesOwnerScope` receives a good row that simply has no `owner_id` key, reads `owner == null`, and refuses. Because nothing threw, `writeGateFailClosed` was never reached and **nothing was logged anywhere**: the operator got a bare 403 with no trace, at every write depth (`org` included — the null-owner short-circuit runs before the scope is consulted). Only a `modifyAllRecords` holder could still write.
+
+ `SharingService` now emits `PHANTOM_ANCHOR_WRITE_DENY_NOTICE` at `warn` on that path, naming the object, the owner field and the caller, with both remedies in the wording: declare the real remote owner column, or move the object off an owner-scoped sharing model. The constant is exported so a deployment can match on it.
+
+ Deduped **per object**, for the service's lifetime. The condition is a property of the registered schema, identical for every row and every caller, so a bulk write emits one line rather than one per row and one misconfiguration is not multiplied by the principal count.
+
+ It fires only on the phantom anchor, never on an ordinary owner-less row: the discrimination is `hasPhantomOwnerAnchor` provenance (is this `owner_id` the platform's injected constant, or a column the author declared?), not `owner == null` and not an `external` test. A federated object with a real declared remote owner column keeps scoping normally and stays silent.
+
+ The diagnostic cannot cost a write — it returns `void`, its caller ignores it, and a throwing logger is swallowed, so no ordering of schema lookup, latch and logger can move a verdict.
+
+ Also corrected in passing: this package attributed the driver's non-throwing unknown-column recovery to **SQLite specifically**. That understated it — the projection rung is gated by the driver's single shared `isUnresolvableColumnError` predicate, which spells all three dialects it speaks (`no such column`, `column … does not exist`, and since #8926 `Unknown column '…'`), so the silent refusal reproduced on every supported dialect. Wording only; no driver change.
+
+### Patch Changes
+
+- 4ea921c: Repair the ADR-0090 `sys_role` → `sys_position` rename in the es-ES object
+ translation bundles, and guard it mechanically.
+
+ The rename half-landed in Spanish: an unreviewed substring find-replace produced
+ two non-words (`Puestoes` as the plural of `Puesto`, and `contpuesto` where the
+ replace ate the unrelated word `control`), while nine further leaves in
+ `plugin-security` and three in `plugin-sharing` were missed entirely and still
+ named the pre-rename concept. In `plugin-sharing` the same picklist key rendered
+ two different ways in one file — `position` was `Puesto` on the sharing rule and
+ `posición` on the record share, and `unit_and_subordinates` read `Rol y
+ subordinados` (naming the removed role concept) against `Unidad de negocio y
+ subordinados` on its sibling.
+
+ Spanish-facing admins saw `Puestoes` as the object's plural label in navigation
+ and list views, and two different words for one recipient kind across two Setup
+ screens.
+
+ Two regression guards now cover the classes involved: a malformed-compound and
+ stale-term check on the renamed security objects, and a self-consistency check
+ asserting that a picklist option key shared by several sharing objects renders
+ identically within a locale. Neither needs a reader of the locale to review it.
+- b705a6c: Repair the ADR-0090 `sys_role` → `sys_position` rename in the ja-JP object
+ translation bundle, and extend the mechanical guard to cover it.
+
+ `sys_record_share.fields.recipient_id.help` still read "...ユーザー/グループ/ロールの
+ ID" — naming the pre-rename `role` concept — while the same bundle already
+ rendered the renamed concept correctly, twice, as `ポジション`
+ (`recipient_type.options.position` on both sharing objects), and the English
+ source for this exact leaf says `position`. Japanese-facing admins saw the
+ stale word in the Setup field-help tooltip for Record Share's `Recipient` field.
+
+ `recipient-vocabulary-consistency.test.ts` (added when the es-ES half of this
+ same rename damage was repaired) now asserts a ja-JP stale-term rule alongside
+ the existing es-ES one, generalised into one per-locale table so a future
+ locale's rule is one entry, not a parallel `describe` block. The ja-JP pattern
+ excludes `ロールアップ` (rollup) and `ロールバック` (rollback) by lookahead rather
+ than `\b`, which does not bound katakana in JS regex (`\w` is ASCII-only) and
+ would otherwise match nothing at all.
+- fab693b: fix(sharing): `publicSharing.eligibility` binds declared fields through the canonical `materializeDeclaredFields` instead of a local copy (#8489)
+
+ `share-link-service.ts` carried its own `bindDeclaredFields` — a hand-written
+ mirror of `@objectstack/objectql`'s `materializeDeclaredFields`, named as a copy
+ in its own doc comment. It is retired; `assertEligible` now imports the
+ canonical helper from `@objectstack/objectql/core` (already a runtime dependency
+ of this package), with a spread at the call site because the canonical
+ materialises in place.
+
+ **This changes eligibility verdicts on exactly one row shape**, and the change
+ was accepted knowingly (maintainer ruling, 2026-08-16). The retired mirror bound
+ a declared field by key PRESENCE (`!(name in record)`); the canonical binds by
+ VALUE (`record[name] === undefined`). They agree on every other input class,
+ including a missing or malformed `fields` map, where both return the record
+ untouched. Where they differ is a declared field held as an own key whose value
+ is `undefined` — a shape `InMemoryDriver` measurably produces (an explicit
+ `undefined` on `create` survives to `find`) and `SqlDriver` structurally cannot
+ (a SQL NULL arrives as `null`).
+
+ On that shape only, with a declared `status`:
+
+ | eligibility predicate | before | after |
+ |:------------------------------|:-------------------------------|:-------------------------|
+ | `record.status == null` | 422 `ELIGIBILITY_UNEVALUABLE` | **link is minted** |
+ | `has(record.status)` | 422 `RECORD_NOT_ELIGIBLE` | **link is minted** |
+ | `!has(record.status)` | **link was minted** | 422 `RECORD_NOT_ELIGIBLE` |
+ | `record.status == 'published'`| 422 `ELIGIBILITY_UNEVALUABLE` | 422 `RECORD_NOT_ELIGIBLE` |
+
+ The first two rows widen acceptance: the predicate is now *answered* rather than
+ faulting on a key CEL reads as absent, and on this fail-closed gate a fault was a
+ refusal. The third row is the one that mattered for the decision — it **closes an
+ over-acceptance**. `has()` guards an UNDECLARED key and never an empty value once
+ bindings are materialised, so `!has(record.)` is false; the
+ mirror was minting share links there that every other server-side surface
+ refuses. The fourth row keeps its direction and changes only its ADR-0112 `code`.
+
+ The eligibility pin is rewritten to discriminate (#9085): its previous
+ declared-field case passed with the binder fully ablated, because every seeded
+ row carried the field it claimed was absent. The replacements use a declared
+ field the stored row genuinely does not carry, and fail in opposite directions
+ under ablation.
+- b53d38e: fix(plugin-sharing): stamp the filter-subtree provenance mark at the read merge, so an author's own cross-field refusal stops being redacted on the sharing-composed path (#8430)
+
+ `#8220` declared the filter-subtree provenance mark and set it at two read-scope
+ merge boundaries — `plugin-security`'s CRUD injection and `service-analytics`'
+ `withReadScope`. `plugin-sharing`'s read path is a **third**: on every read it
+ AND-composes an OWD / record-share visibility filter into `ast.where`, and it
+ stamped nothing.
+
+ Two marks, and they are not the same job:
+
+ - **the scopes it injects are marked `'policy'`** — the OWD/record-share read
+ filter, the delegator's intersected filter (ADR-0090 D10) and the
+ `sys_record_share` self-scope (ADR-0111 D5). **No behaviour change**: an
+ unmarked subtree already withheld, so these refusals kept the `#7929`
+ redaction before and keep it now. What changes is that the withhold becomes a
+ *declared* verdict instead of an accident of the mark's absence — which
+ matters because an unmarked node **inherits its ancestor's mark positionally**
+ (`resolveFilterSubtreeProvenance`, innermost wins), so an unmarked policy arm
+ nested inside a vouched subtree would read as the author's.
+ - **the caller's own predicate is vouched `'author'`** immediately before the
+ rewrite that would otherwise make it unrecognisable to every later boundary.
+ This is the one user-visible change: an author's own `{ $field }` refusal on
+ an object with active sharing again names its columns, its operator and its
+ reason, instead of the redacted "operands withheld" text.
+
+ **The vouch is an identity check, not a heuristic.** The mark is stamped only
+ while `ast.where` is still, by object identity, the `where` the caller handed
+ the engine. If a sibling middleware already composed into it, or the engine
+ rewrote it resolving filter tokens, identity fails and **nothing** is vouched —
+ the tree stays unmarked, and unmarked withholds. The arms of a pure
+ `{ $and: [ … ] }` root are vouched too, because `composeAnd`'s flattening branch
+ spreads that root's arms into a new object and would otherwise drop the vouch
+ out of the tree with it (that shape is what the array authoring form lowers to,
+ so it is the common case, not an edge one).
+
+ **Fail-closed is unchanged in every direction**, and the pins say so at a real
+ `SqlDriver`: the injected scope still withholds, a policy arm sitting beside an
+ author-vouched arm in the same `$and` still withholds, and a predicate no
+ boundary ever vouched still withholds byte-identically to the policy case.
+
+ **The write path is untouched.** `buildWriteFilter`'s composition is a different
+ question with different consequences and was not declared by `#8220`.
+
+ Measured while implementing, and worth recording because the card says
+ otherwise: in a stack that composes **both** plugins, the author vouch was
+ already surviving. `plugin-security` is registered before `plugin-sharing` on
+ both real boot paths and `resolvePluginOrder` preserves insertion order, so
+ security vouches first and its mark — which lives on the caller's object —
+ travels through this composition untouched. The gap this fixes is a stack that
+ mounts `plugin-sharing` **without** `plugin-security`, where nothing else can
+ vouch for the caller.
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [e374b4d]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [a751f7d]
+- Updated dependencies [eccb8b2]
+- Updated dependencies [650cd3d]
+- Updated dependencies [b735507]
+- Updated dependencies [91c6c28]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2d0af57]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [4e71ae1]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [4dfa369]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [5e2f594]
+- Updated dependencies [e2899f6]
+- Updated dependencies [b6c7690]
+- Updated dependencies [855591f]
+- Updated dependencies [3851f87]
+- Updated dependencies [845e164]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [326f5de]
+- Updated dependencies [30d3752]
+- Updated dependencies [21995d7]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [6a5e6ad]
+- Updated dependencies [30b1c63]
+- Updated dependencies [7fc01db]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [b2a451f]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [7c2f386]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [8a9e7f4]
+- Updated dependencies [3d0ded8]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [1258dca]
+- Updated dependencies [91c4ff5]
+- Updated dependencies [d634e66]
+- Updated dependencies [682b86b]
+- Updated dependencies [6a1b45e]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/objectql@17.1.0
+ - @objectstack/metadata-core@17.1.0
+ - @objectstack/formula@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json
index 1401950d47..2f28e1bd34 100644
--- a/packages/plugins/plugin-sharing/package.json
+++ b/packages/plugins/plugin-sharing/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/plugin-sharing",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Record-level sharing for ObjectStack — sys_record_share + middleware that enforces sharingModel + ISharingService.",
"main": "dist/index.js",
diff --git a/packages/plugins/plugin-webhooks/CHANGELOG.md b/packages/plugins/plugin-webhooks/CHANGELOG.md
index a9ec4cd1a1..866f574f8b 100644
--- a/packages/plugins/plugin-webhooks/CHANGELOG.md
+++ b/packages/plugins/plugin-webhooks/CHANGELOG.md
@@ -1,5 +1,210 @@
# @objectstack/plugin-webhooks
+## 17.1.0
+
+### Patch Changes
+
+- 90417a8: chore(plugin-webhooks): `sys_webhook` declares its data-API exposure explicitly — recording the posture, not narrowing it (#9756)
+
+ `sys_webhook` shipped with no `enable` block at all, so it kept the full default
+ data API. Three cards each noticed and each named the narrowing as the next
+ step — #7799 (the signing secret), #7986 (the custom headers), #8025 option 2
+ (the URL) — and each assumed a later one would write the line. None did, and the
+ last of them closed `completed` with the line still unwritten. The posture was
+ never a judgement; it was a default nobody had written down.
+
+ It is written down now:
+
+ ```ts
+ enable: { apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'] }
+ ```
+
+ **The effective surface is unchanged, and that is the honest headline.** The set
+ is derived from a census of who actually reaches the object, taken before
+ anything was edited:
+
+ | consumer | reaches it through | needs |
+ |:---|:---|:---|
+ | Setup/Studio console — `nav_webhooks`, four list views, `userActions` create/edit/delete | REST `/api/v1/data/sys_webhook` (gated) | `get` `list` `create` `update` `delete` |
+ | Operator predicate write — "deactivate every webhook on an object" (#4639) | REST `updateMany`/`deleteMany` (gated on `bulk`) | `bulk` |
+ | `AutoEnqueuer`, `bootstrapDeclaredWebhooks`, the provenance stamp, `redeliver-guard`, the secret sweep | `engine.*` and lifecycle hooks — ObjectQL directly, which never consults `enable.apiMethods` | ungated |
+
+ Every primitive is required by a real consumer, so the set is all six — whose
+ operation closure is what the absent block already produced. Nothing that was
+ reachable becomes unreachable, and `/me/permissions` reports the identical
+ `apiOperations` array. No caller needs to change anything.
+
+ ⛔ **Do not read this as the read-surface narrowing those three cards asked
+ for.** It is not one, and `apiMethods` cannot be one here: `url` (#8025 —
+ won't-fix on masking, because the URL is the routing key an operator must be
+ able to see, search, sort and edit) and a legacy row's un-migrated
+ `definition_json.headers` (#7986 — still read, and warned about, by
+ `readLegacyHeaders`) are served by `get`/`list`, which is exactly what the admin
+ console requires. Any set that removes them removes the admin surface too. The
+ sibling `sys_http_delivery` can hold `['get','list']` because it is engine-owned
+ and never authored; `sys_webhook` is a first-class admin authoring surface.
+
+ The equality above is pinned in `sys-webhook-api-exposure.test.ts` rather than
+ left as a claim, so a later change that does move the surface has to say so.
+- b278695: fix(webhooks): refuse a malformed `sys_webhook.headers_secret` at the write door instead of at the next delivery (#8566)
+
+
+
+ `sys_webhook.headers_secret` is a `Field.secret()` whose plaintext is **not** an
+ opaque blob: it is a serialized header map with a required shape — a flat JSON
+ object of string values — and `parseStoredHeaders` is its only reader. Nothing
+ validated that shape on the way in. The ordinary data API accepted any string,
+ encrypted it like any other secret, minted a real `sys_secret` row, and left the
+ column holding a perfectly valid `secret:` ref that read back as the mask with
+ `active: true`.
+
+ Measured on a real engine through `engine.update()` — the ordinary data API, no
+ privileged access — every one of these was **accepted** and is a value the
+ plugin can never use: `{}`, `[]`, `{"X-Count": 5}`, a nested object, and
+ `{X-Team: crm}` (a typo). The field is directly admin-authorable and its own
+ description instructs the author to type a JSON object into it, which makes a
+ typo the *expected* failure rather than an exotic one.
+
+ **This is not an exposure fix and must not be read as one.** #8558/#8565 already
+ closed the consumer half: a webhook whose stored header map does not come back
+ as a flat string map parks the subscription and reports at `error`, rather than
+ delivering header-less with a valid signature. Nothing leaks, and nothing is
+ silently lost today. What this changes is **when the author finds out** — at the
+ write door where they typed it, instead of at the next matching record change,
+ an unbounded time later and in a different surface.
+
+ **What is refused:** a `headers_secret` plaintext that does not parse back as a
+ flat JSON object of string values with at least one entry, with a located
+ ADR-0112 `VALIDATION_ERROR` / 400 naming `sys_webhook.headers_secret`, quoting
+ the shape the field's own description asks for, and diagnosing the specific
+ spelling (invalid JSON / an array / an empty object / which key's value is not a
+ string). ⛔ The message never echoes the rejected value — this column carries
+ credentials, and quoting the input would print an `Authorization: Bearer …` into
+ logs and error bodies, re-opening in the diagnostic exactly the exposure #7986
+ moved this field onto the encrypted channel to close. It names header *keys* and
+ value *types* only.
+
+ **What stays accepted, byte for byte:** every valid flat string map (as JSON
+ text, or as an authored object the engine serializes into the same form); `null`
+ to clear; an omitted key to leave the stored value unchanged; and an **echoed
+ read-mask**, so the ordinary Setup-form round-trip (GET a row, edit an unrelated
+ field, PATCH it back) is untouched. `""` is deliberately passed through to
+ #8559's `EmptyCredentialWriteError` rather than re-refused here — one door, one
+ owner, one message.
+
+ **Where it runs, and why that is the whole mechanism:** a `beforeInsert` /
+ `beforeUpdate` hook on `sys_webhook`, bound by `WebhookOutboxPlugin` before its
+ first seeded write. It has to run *before* the engine's `encryptSecretFields` —
+ one step later the plaintext is gone and the column holds an opaque ref, so a
+ validator behind it would have nothing left to validate. The suite measures that
+ ordering rather than asserting it: every refusal pins that **no `sys_secret`
+ cipher row was minted**, which is only true if the gate ran first.
+
+ A hook rather than checks on the plugin's own write paths
+ (`bootstrapDeclaredWebhooks` / `headersPatch` / the migration sweep), because a
+ direct `PATCH /api/v1/data/sys_webhook` goes through none of them and that is
+ the measured trigger. Those paths inherit the validation through the hook and
+ deliberately carry no second check.
+
+ A general `secret`-channel plaintext validator — letting any `secret`-typed
+ field declare its own plaintext shape — is the principled generalization and is
+ recorded as the **promotion path**, not built here: it becomes the shape the
+ moment a second shaped-plaintext `secret` field exists (maintainer ruling
+ 2026-08-13; one consumer does not justify a general capability).
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/service-messaging@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json
index 62fde4210e..5e90c42de2 100644
--- a/packages/plugins/plugin-webhooks/package.json
+++ b/packages/plugins/plugin-webhooks/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/plugin-webhooks",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Persistent, cluster-aware webhook dispatcher. Durable outbox + per-partition cluster.lock for exactly-once-ish delivery across nodes. See content/docs/concepts/webhook-delivery.mdx.",
"type": "module",
diff --git a/packages/qa/dogfood/CHANGELOG.md b/packages/qa/dogfood/CHANGELOG.md
index 5bea6f0a63..a592dc74aa 100644
--- a/packages/qa/dogfood/CHANGELOG.md
+++ b/packages/qa/dogfood/CHANGELOG.md
@@ -1,5 +1,334 @@
# @objectstack/dogfood
+## 0.0.41
+
+### Patch Changes
+
+- 2ce1eb4: docs(qa): narrow the ADR-0056 D10 authz conformance matrix's advertised completeness claim to what its ratchet actually checks (#8711)
+
+ The matrix header and its companion test's header previously read as though
+ a new declared-but-unenforced authorization primitive would "break CI." It
+ would not, for most of the ledger: the completeness `discover()` ratchets is
+ over a **curated table of HTTP/transport entry points** (15 probes over 11
+ named source files), not over primitives. A primitive enforced by a predicate
+ inside an existing resolver — the `sys_permission_set.active` /
+ `sys_position.active` rows added in #8812 are the normal case, not an
+ exception — adds no entry point, so it can be neither UNCLASSIFIED nor STALE.
+
+ Both headers now say so explicitly, carrying the measured numbers so the
+ narrowed claim is load-bearing rather than vague: 43 of the matrix's 50 rows
+ carry no `covers` key at all, 37 of the 43 `enforced` rows are exactly that
+ in-resolver shape, and — preserved, because it is real — 5 of the file's 9
+ `covers` keys are gate-pins that vanish (and fail CI) when the guard call
+ they name is deleted. Prose and comments only; nothing about the ratchet's
+ checking behaviour, the `discover()` table, or any row changes. Maintainer
+ ruling on #8711 (Option A): narrow the claim, do not build a
+ primitive-discovery ratchet (measured unachievable in general form).
+- 3c4c2ff: 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.
+- 0bb8dbd: 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.
+- 2d0af57: 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).
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [e43d63a]
+- Updated dependencies [e374b4d]
+- Updated dependencies [1408fe3]
+- Updated dependencies [fe90efa]
+- Updated dependencies [445ae4d]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [a751f7d]
+- Updated dependencies [eccb8b2]
+- Updated dependencies [650cd3d]
+- Updated dependencies [b735507]
+- Updated dependencies [91c6c28]
+- Updated dependencies [75b7c24]
+- Updated dependencies [cf0d902]
+- Updated dependencies [498f4e8]
+- Updated dependencies [cc5c07b]
+- Updated dependencies [d9813a9]
+- Updated dependencies [4c178c1]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [04d03c3]
+- Updated dependencies [2d0af57]
+- Updated dependencies [7337f30]
+- Updated dependencies [420804d]
+- Updated dependencies [8656d67]
+- Updated dependencies [716ac9b]
+- Updated dependencies [e9534a4]
+- Updated dependencies [6feac91]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [4ea921c]
+- Updated dependencies [0ccea4a]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [19539b4]
+- Updated dependencies [b705a6c]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [4e71ae1]
+- Updated dependencies [739fe5b]
+- Updated dependencies [20067c5]
+- Updated dependencies [d09d0fd]
+- Updated dependencies [5ed8ee6]
+- Updated dependencies [ff4ba6a]
+- Updated dependencies [f9d7acf]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [2a9752c]
+- Updated dependencies [b348ac2]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [4dfa369]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [5e2f594]
+- Updated dependencies [e2899f6]
+- Updated dependencies [bbd86ed]
+- Updated dependencies [b6c7690]
+- Updated dependencies [855591f]
+- Updated dependencies [3851f87]
+- Updated dependencies [c73eacd]
+- Updated dependencies [f8537df]
+- Updated dependencies [712e185]
+- Updated dependencies [d693ba1]
+- Updated dependencies [53fc099]
+- Updated dependencies [693c788]
+- Updated dependencies [845e164]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [0425db9]
+- Updated dependencies [cd455c8]
+- Updated dependencies [326f5de]
+- Updated dependencies [30d3752]
+- Updated dependencies [b3de42c]
+- Updated dependencies [21995d7]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [6a5e6ad]
+- Updated dependencies [30b1c63]
+- Updated dependencies [7fc01db]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [f01c0ee]
+- Updated dependencies [fab693b]
+- Updated dependencies [b53d38e]
+- Updated dependencies [06f9848]
+- Updated dependencies [b0fa4fc]
+- Updated dependencies [4012a70]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [b2a451f]
+- Updated dependencies [c25b2d5]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [147eadc]
+- Updated dependencies [90417a8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [7c2f386]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [8a9e7f4]
+- Updated dependencies [3d0ded8]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [1258dca]
+- Updated dependencies [91c4ff5]
+- Updated dependencies [d634e66]
+- Updated dependencies [682b86b]
+- Updated dependencies [6a1b45e]
+- Updated dependencies [b278695]
+- Updated dependencies [5126e79]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+ - @objectstack/plugin-auth@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/plugin-security@17.1.0
+ - @objectstack/objectql@17.1.0
+ - @objectstack/plugin-audit@17.1.0
+ - @objectstack/plugin-email@17.1.0
+ - @objectstack/plugin-sharing@17.1.0
+ - @objectstack/metadata@17.1.0
+ - @objectstack/service-messaging@17.1.0
+ - @objectstack/mcp@17.1.0
+ - @objectstack/service-analytics@17.1.0
+ - @objectstack/service-storage@17.1.0
+ - @objectstack/metadata-core@17.1.0
+ - @objectstack/example-showcase@0.3.15
+ - @objectstack/plugin-webhooks@17.1.0
+ - @objectstack/example-crm@4.0.93
+ - @objectstack/connector-mcp@17.1.0
+ - @objectstack/connector-openapi@17.1.0
+ - @objectstack/connector-rest@17.1.0
+ - @objectstack/verify@17.1.0
+
## 0.0.40
### Patch Changes
diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json
index 9fcfd93b45..e26cfa5176 100644
--- a/packages/qa/dogfood/package.json
+++ b/packages/qa/dogfood/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/dogfood",
- "version": "0.0.40",
+ "version": "0.0.41",
"private": true,
"license": "Apache-2.0",
"description": "Dogfood regression gate — hand-written golden tests that boot real example apps through @objectstack/verify's in-process HTTP stack, pinning historical runtime regressions (#2018 timezone bucketing, #1994 cross-owner RLS, #2004 field fidelity) that static checks miss.",
diff --git a/packages/qa/downstream-contract/CHANGELOG.md b/packages/qa/downstream-contract/CHANGELOG.md
index d08dbad6ef..76d2742aa4 100644
--- a/packages/qa/downstream-contract/CHANGELOG.md
+++ b/packages/qa/downstream-contract/CHANGELOG.md
@@ -1,5 +1,88 @@
# @objectstack/downstream-contract
+## 0.0.39
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [19539b4]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+
## 0.0.38
### Patch Changes
diff --git a/packages/qa/downstream-contract/package.json b/packages/qa/downstream-contract/package.json
index 3995738708..bb70093570 100644
--- a/packages/qa/downstream-contract/package.json
+++ b/packages/qa/downstream-contract/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/downstream-contract",
- "version": "0.0.38",
+ "version": "0.0.39",
"description": "Frozen third-party consumer fixture — a backward-compatibility gate for @objectstack/spec. Authored the way an external project on a published release authors metadata; if a spec change breaks it, that change is breaking (#2035).",
"license": "Apache-2.0",
"private": true,
diff --git a/packages/qa/http-conformance/CHANGELOG.md b/packages/qa/http-conformance/CHANGELOG.md
index c28ca96acb..043ce86b81 100644
--- a/packages/qa/http-conformance/CHANGELOG.md
+++ b/packages/qa/http-conformance/CHANGELOG.md
@@ -1,5 +1,55 @@
# @objectstack/http-conformance
+## 0.1.1
+
+### Patch Changes
+
+- 7ff3975: 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.
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [5f5e234]
+- Updated dependencies [7ff3975]
+- Updated dependencies [24173e9]
+- Updated dependencies [f8eb736]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [402c125]
+ - @objectstack/core@17.1.0
+
## 0.1.0
### Minor Changes
diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json
index 2b47f7ba3b..3f89c62b48 100644
--- a/packages/qa/http-conformance/package.json
+++ b/packages/qa/http-conformance/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/http-conformance",
- "version": "0.1.0",
+ "version": "0.1.1",
"private": true,
"license": "Apache-2.0",
"description": "HTTP transport-port conformance gate (ADR-0076 D11/OQ#10, #2462) — a zero-dependency node:http reference implementation of IHttpServer plus a cross-adapter suite that boots the dispatcher bridge and REST generator on it AND on plugin-hono-server, pinning that the port stays free of framework-isms. Not published; validation instrument, not a product server.",
diff --git a/packages/rest/CHANGELOG.md b/packages/rest/CHANGELOG.md
index 7c84b9a7eb..6ac91ddf9e 100644
--- a/packages/rest/CHANGELOG.md
+++ b/packages/rest/CHANGELOG.md
@@ -1,5 +1,976 @@
# @objectstack/rest
+## 17.1.0
+
+### Minor Changes
+
+- 66dbec4: 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.
+- b537855: fix(rest): `POST /meta/:type/:name/publish` and `.../rollback` require the `manage_metadata` capability (#8919)
+
+
+
+ **BREAKING for any integration that publishes or rolls back metadata with a
+ principal holding no authoring capability.** Landing after the v17.0.0 cut, so
+ it ships as `minor` under the lockstep launch-window convention.
+
+ `packages/rest` gates four metadata-authoring doors on ADR-0066 D1's
+ `manage_metadata` capability — `POST /meta/_migrate-stored`, `PUT /meta/:type/:name`
+ (#6603), `PUT /meta/:type/:section/:name` and `DELETE /meta/:type/:name` (#7019).
+ The two **promotion** verbs did not, and promotion is what decides which body is
+ live: `publishMetaItem` flips the `sys_metadata` row `state: 'draft'` to
+ `'active'` (ADR-0027 (E)(5) defines sealing a publish as exactly that flip), and
+ `rollbackMetaItem` restores a caller-supplied `toVersion` as the new live row.
+
+ **Measured through a composed host, down to the protocol layer, before the fix:**
+
+ | principal | publish | rollback |
+ |:--|:--|:--|
+ | anonymous | 401, protocol not reached | 401, protocol not reached |
+ | authenticated, **no** `manage_metadata` | **200, protocol reached** | **200, protocol reached** |
+ | authenticated, `manage_metadata` | 200, protocol reached | 200, protocol reached |
+
+ So the reachable cohort was every authenticated principal holding no authoring
+ capability at all: it could take a draft somebody else authored and make it
+ live, or restore any historical version over the live row. Anonymous callers
+ were already refused by the `/meta` umbrella (`registerMetadataEndpoints`), so
+ what these gates add is precisely the authenticated-but-uncapable cohort.
+
+ **`rollback` is the sharper of the two.** The caller supplies `toVersion`, which
+ makes it a mechanism for reverting security hardening — a permission set as it
+ stood before it was tightened, a validation rule from before it existed, a
+ layout from before field-level security. It is also the door with the least
+ behind it: publish at least re-runs `assertRuntimeAuthoringRules` on the
+ promoted draft (#4463 D1), while rollback runs no content gate at all. Neither
+ of those reads the caller in any case — D1 answers "is this metadata valid", not
+ "may you press this button" — so nothing downstream was ever doing this job.
+ Audit rows are still written either way, so the action remains traceable after
+ the fact.
+
+ **No legitimate caller loses anything, and that is measured rather than
+ assumed.** The Studio designer's save-then-publish loop saves `?mode=draft` and
+ then POSTs `/publish`, and its **first** step already demanded
+ `manage_metadata` — so every principal that can author a draft already clears
+ the new gate. The shipped sets bear this out: `admin_full_access` (the only set
+ carrying `studio.access`) carries `manage_metadata` too, while
+ `organization_admin` and `member_default` are refused at the save door **today**.
+ The only callers the gap benefited were exactly the ones already refused the
+ authoring door — able to promote a draft they could not have written.
+
+ **Migration — grant `manage_metadata` to any service principal that publishes.**
+ An integration that promotes metadata on its own schedule (a CI job sealing a
+ release, an AI authoring agent) needs the capability explicitly; there is no
+ automatic replacement, deliberately. `isSystem` contexts bypass, as on every
+ other capability gate on the platform, so in-process callers are unaffected.
+
+ The gate is the sibling doors' four lines verbatim, deliberately not a second
+ way of demanding the same capability, and it fires **before** the protocol is
+ resolved so 403-vs-501 leaks no kernel capability and nothing is promoted before
+ the refusal.
+
+ ⚠️ **An author/publisher capability split is NOT introduced here.** Separating
+ "may write a draft" from "may make it live" is a defensible design, but it needs
+ a *different* declared capability and is a product decision; both defensible
+ designs require a gate, and the state this fixes was neither.
+
+ Ships with an **enumeration pin** rather than two assertions. The defect was not
+ that two handlers forgot a gate — it was that the gate was a convention held by
+ repetition and nothing else, so the next metadata write door had a one-in-three
+ chance of copying an ungated neighbour with no test going red. The new suite
+ derives the write doors from the composed server's own route table and compares
+ them against a declared list, so a new mutating `/meta` route fails the build
+ until it is enumerated and its refusal asserted.
+- 4dc8a61: **Audit attribution change — the recorded actor on `/meta` writes is now the authenticated identity, and `X-Actor` is ignored.** All five `/meta` write sites (save, delete/reset, publish, rollback, compound save) stamp `sys_metadata_audit.actor` and `sys_metadata_history.recorded_by` with the identity the request was actually authorized as. A request that sends `X-Actor` is recorded against its own authenticated caller, not the header's value. Maintainer ruling 2026-08-12 on #7941, re-confirmed 2026-08-15.
+
+ Why: the header used to outrank the authenticated identity. That ordering was inert for as long as the other limb produced nothing — `req.user` / `req.userId` are never set on this transport — so nothing depended on it. Fixing that producer (#7749) made the precedence load-bearing for the first time, and what it then meant was that any caller already holding `manage_metadata` could sign somebody else's name to a metadata write: the compliance trail answered "who *claimed* to change this" rather than "who changed this", which is the question #7749 was filed to make answerable. Attribution now cannot drift from authorization, because both read the same `resolveExecCtx` the route's own capability gate reads.
+
+ The header limb is **removed rather than reordered**. The ruling permitted keeping it for genuine machine/system callers with no authenticated user, but only if a consumer census showed that shape exists — it does not, so a caller cannot choose the recorded name in any shape, including on the machine-write path where there is no identity for the header to lose to.
+
+ Deliberately unchanged:
+
+ - **Real impersonation still attributes correctly.** The platform's impersonation is session-level (better-auth admin plugin, `sys_session.impersonated_by`), so `resolveExecCtx` already resolves to the impersonated user and their metadata writes are recorded against them. Nothing in that path went through `X-Actor`.
+ - **Machine and anonymous writes.** No resolved principal still means no actor, so the protocol's own `'system'` / `NULL` defaults apply exactly as before — a machine write is never stamped with a real user.
+ - **Sending `X-Actor` is not an error.** It is ignored, not rejected; no request that succeeds today starts failing.
+
+ Who is affected: any caller that relied on `X-Actor` to attribute a `/meta` write to somebody other than itself. The census over `objectstack` and `objectui` found no such caller — `objectui`'s `MetadataClient` can send the header through an optional `options.actor`, but nothing in that repo ever passes one, leaving that option inert against this server.
+
+### Patch Changes
+
+- e7bccaa: fix(rest): anchor `looksLikeMissingRelation` on the driver's quoted template (#8264)
+
+ `mapDataError`'s Postgres limb read `relation` and `does not exist` anywhere in
+ the message, not necessarily the same sentence — so ordinary business prose
+ using both words (`This relation does not exist in the diagram`) matched.
+ `does not exist` is ordinary business English; #8132 already anchored the
+ shared `@objectstack/types` leak predicate on the driver's own quoted
+ template for exactly this reason, and pinned the identical string as a
+ negative case. This file's copy of the same question was not covered by that
+ change (different package, different call site) and kept the loose reading.
+
+ Anchored the same way here — a quoted identifier required between `relation`
+ and `does not exist` — as a locally-owned pattern rather than a call into the
+ shared leak predicate: that
+ predicate answers a different question ("may this be withheld from the
+ client"), and its other limbs (`sqlite_`, `unique constraint`, `foreign key`,
+ a bare SQL statement) have nothing to do with this file's question (is this
+ specifically an unknown-relation condition, for the 404-vs-500 split
+ `looksLikeMissingRelation` feeds). `relation-sub-object.ts` documents "two
+ widths, on purpose" for a neighbouring pair of consumers that ask genuinely
+ different questions; that does not extend to the two USES inside this file,
+ which both ask the same question and share one predicate correctly.
+
+ **Both of the predicate's two call sites are covered, not just the reported
+ one:** the `DATA_STORE_FAULT` (500) gate the issue named, and the
+ `looksLikeUnknownObject` (404) limb the issue's own text did not measure. A
+ business message no longer gets mislabelled a `DATABASE_ERROR`, and a
+ crafted unquoted-but-attributable message no longer gets silently answered
+ `OBJECT_NOT_FOUND` — both now fall through to the generic, still-sanitised
+ terminal fault, which is the direction the branch's own #5462 comment already
+ argues for ("the safe way to be wrong is loud").
+
+ No reachable production path producing the unanchored shape was found at this
+ call site — this is consistency/invariant restoration between two spellings
+ of one question, not a fix for a demonstrated live misclassification.
+- 5047cb8: fix(metadata-protocol): scope the metadata audit read to the caller's organization (#8747)
+
+ `ObjectStackProtocolImplementation.auditMetaItem` declared
+ `organizationId?: string | null` and never read it. The comment directly above
+ its query described the filter it would have built — "include rows for the
+ specific org AND env-wide (`organization_id IS NULL`) rows" — while the `where`
+ was exactly `{ type, name }`. The parameter was dead on the caller side too:
+ `GET /api/v1/meta/:type/:name/audit` never passed one.
+
+ The consequence was a cross-tenant disclosure, measured rather than inferred:
+ three saves of one view name under two organizations and env-wide, then one
+ `auditMetaItem({ type, name })` read, returned all three organizations' rows —
+ and with each row its `actor`, `note`, `lock_state`, `code`, `operation`,
+ `source` and `request_id`. Nothing compensated lower down. The driver's tenant
+ wall never engaged, because it is armed only from an execution context this
+ read did not pass; the security plugin's Layer 0 never engaged, because the
+ middleware short-circuits on a principal-less call long before the field gate
+ that would have carried it; and no tenancy posture would have supplied the
+ scope either. The route carries no capability gate — unlike its `PUT` twin,
+ which gates on `manage_metadata` — so the reachable cohort was any
+ authenticated principal of any tenant, on the published `meta.getAudit` SDK
+ surface.
+
+ The query now builds the described filter: rows for the caller's organization
+ plus env-wide (`organization_id IS NULL`) rows, and nothing else. The env-wide
+ limb is load-bearing rather than defensive — the REST `PUT /meta/:type/:name`
+ door passes no organization, so every row it writes is stamped
+ `organization_id: null`, and an equality-only filter would have blanked the
+ audit tab on those deployments instead of scoping it. A read that resolves no
+ organization is fail-closed onto the env-wide rows, symmetric with what an
+ org-less write produces, so omitting the parameter is no longer a skeleton key.
+
+ The REST route supplies the organization from the execution context it already
+ resolves for 40-plus handlers, adding no new organization-resolution plumbing
+ to `packages/rest`. The same call also stopped passing `environmentId`, which
+ the request type never declared and the method body never read; environment
+ scoping is unaffected, since it comes from which protocol instance is resolved
+ rather than from the request payload.
+
+ Behaviour change worth stating plainly: a caller that previously saw another
+ tenant's metadata audit rows for a same-named item no longer sees them. Own-org
+ and env-wide rows are unchanged.
+- ed4ca59: 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.
+- 51a46a4: 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.
+- 3ab2488: fix(rest): stamp the export download's filename in the business timezone (#8484)
+
+ `exportContentDisposition` built the `-YYYYMMDD-HHMMSS` half of the suggested
+ filename from process-local getters (`now.getFullYear()` / `getHours()` / …),
+ which read the deployment host's `TZ` — a hosting fact, not the caller's
+ business timezone. The route had already resolved that timezone one frame up
+ (`ExecutionContext.timezone`, the platform-default → global → tenant cascade)
+ and simply never passed it here.
+
+ After #8373 moved the export's **contents** onto the business timezone, the
+ filename was the last export surface still on the host clock, so the two
+ disagreed exactly when `TZ` was not the business zone: a container at `TZ=UTC`
+ serving an Asia/Shanghai tenant downloaded `orders-20260731-220000.csv` whose
+ first row read `2026-08-01 06:00:00` — off by a day, and at a month boundary by
+ a month. The name and the rows inside it now read one clock.
+
+ **The no-timezone fallback stays PROCESS-LOCAL, deliberately not UTC.** This is
+ the opposite of the cell path's UTC fallback, and the asymmetry is the point:
+ each fallback preserves the historical output of the surface it serves. The
+ cells were hardcoded to UTC before #8373; this filename has always used the
+ process clock. Defaulting it to UTC would look safer while silently re-timing
+ the filename of every deployment that sets a host `TZ` but resolves no business
+ timezone — a user-visible rename for zero correctness gain. An explicitly
+ resolved `'UTC'` is a *resolved* zone, not a missing one, and does produce a UTC
+ stamp regardless of the host.
+
+ The shared clock helper is split rather than parameterised with a default:
+ `zonedWallClock` now returns `null` when no usable zone resolves, and each of
+ the two callers supplies its own fallback at the call site where it can be read
+ and pinned. Baking either fallback into the shared helper would silently
+ re-time the other surface.
+
+ Filename **naming** is untouched — label selection, sanitization and the RFC
+ 5987/6266 `filename*` encoding all behave exactly as before, and the export's
+ contents are not touched at all.
+- 185c7bd: 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.
+- 45862a5: 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.
+- 24173e9: fix(rest): read an offset-free import cell in the business timezone, not the host `TZ` (#8485)
+
+ `parseDateCell` ended in `new Date(s)`. A spreadsheet cell like
+ `2026-08-01 06:00:00` carries no offset, so ECMAScript resolves it against the
+ **process** timezone, and the instant bulk import stored became a property of
+ the deployment host:
+
+ ```
+ TZ=Asia/Shanghai → 2026-07-31T22:00:00.000Z
+ TZ=UTC → 2026-08-01T06:00:00.000Z
+ ```
+
+ Same file, same tenant, same cell — eight hours apart, decided by a setting
+ nobody authoring the spreadsheet can see, and never consulting the business
+ timezone the route had already resolved one frame up
+ (`ExecutionContext.timezone`, the platform-default → global → tenant cascade).
+
+ Since the export renders `datetime` cells in that business timezone (#8373), the
+ advertised export → edit in a spreadsheet → re-import round trip was lossless
+ only where the host `TZ` happened to equal the business zone. `import-coerce.ts`
+ opens by calling itself "the inverse of `export-format.ts`"; it now is one, and
+ the regression proof asserts inverse-ness on the **pair** — every fixture under
+ a host `TZ` deliberately different from the business timezone, because a test
+ that runs only under a matching `TZ` cannot fail.
+
+ **An offset-free datetime cell is now read in the caller's business timezone**,
+ through `@objectstack/core`'s new `zonedWallClockToUtcMs` — the DST-safe wall
+ clock → instant primitive that `zonedDateStartToUtcMs` (the date-bucket drill
+ path) is now the midnight special case of. One implementation of zone
+ arithmetic, `Intl` offsets from the platform tz database, never hand-rolled;
+ generalising the existing one rather than hand-rolling a second in `rest` is
+ what keeps the export and import halves of this seam from drifting apart again.
+ Two wall clocks are not a bijection with instants, and both degenerate DST
+ readings resolve to the earlier candidate instant — a gap reading lands just
+ before the gap, an ambiguous reading on its first occurrence (pinned, measured).
+
+ Three things deliberately do **not** move:
+
+ - **A cell that carries an explicit offset** (`…Z`, `…+08:00`) already names one
+ instant and is honoured exactly as written. This change affects naive cells
+ only.
+ - **The date-only fast path stays UTC.** `YYYY-MM-DD` is UTC per ECMAScript and
+ a `date` is a timezone-naive calendar day (ADR-0053); sweeping it into the
+ zoned handling to make the code look uniform would silently re-time every
+ date-only import to fix nothing.
+ - **No timezone resolved ⇒ UTC**, never the process clock. That is the fallback
+ the export's cell path takes in the same case, so the round trip stays exact
+ for deployments that configure no zone — and a process-`TZ` fallback would
+ preserve the defect for exactly the deployments that cannot see it. This is
+ the one **behaviour change for existing deployments**: a host with a non-UTC
+ `TZ` and no resolved business timezone previously read naive cells in the host
+ clock and now reads them as UTC. An explicitly resolved `'UTC'` is a resolved
+ zone, not a missing one.
+
+ Two adjacent legs of the same defect, both on the naive-cell path:
+
+ - **A naive cell landing in a `date` or `time` field** now takes the typed
+ components verbatim (`2026-08-01 06:00:00` → `2026-08-01` / `06:00:00`).
+ Those branches also read the process clock, so a host east of the cell stored
+ the *previous calendar day* for a `date` column.
+ - **An xlsx date cell.** An Excel serial date carries no timezone; ExcelJS
+ materialises it as a `Date` whose UTC components are the sheet's wall clock,
+ and `import-prepare.ts` rendered it with `toISOString()` — stamping a `Z` the
+ file never had. That fabricated offset then outranked the business timezone by
+ the very carve-out above, so every real date cell in a user-authored workbook
+ imported as UTC whatever the tenant's zone. It now flattens to the same
+ offset-free `YYYY-MM-DD HH:mm:ss` a CSV export writes, which is what that
+ function's contract already claimed to produce.
+- 6cb88d9: fix(rest): `GET /api/v1/meta/:type` refuses a type name that names nothing, instead of serving it as an empty collection (#9488)
+
+
+
+ ```
+ GET /api/v1/meta/totally_invented_type → 200 {"type":"totally_invented_type","items":[]}
+ PUT /api/v1/meta/totally_invented_type/x → 400 "'totally_invented_type' is not a metadata type"
+ ```
+
+ The two doors disagreed about which type names exist. A
+ 200-with-an-empty-collection is **indistinguishable from "this type exists and
+ holds nothing"**, so a typo'd or renamed type name read as an empty surface
+ rather than as a mistake — the same trap `GET /meta/app?id=` was
+ already filed for, where the answer read to a runner as "the app metadata is
+ gone".
+
+ The list door now answers **`400` / `INVALID_REQUEST`**, naming the type: the
+ same status and the same code the write door has emitted since `PUT /meta//x`
+ was closed, so one condition has one answer on both doors. `INVALID_REQUEST` is
+ already registered to `@objectstack/rest` in the ADR-0112 `ERROR_CODE_LEDGER`;
+ no code is minted. The refusal is thrown rather than hand-built, so its wire
+ body is byte-identical to the write door's for the same condition.
+
+ **What still answers `200` with an empty collection**, because a type that
+ exists and has no items is the legitimate case the defect was indistinguishable
+ from — breaking it would be worse than the bug:
+
+ - every member of the static spelling contract (`sharing_rule`, `theme`,
+ `objects`, `api`, …), whether or not the deployment holds one item of it;
+ - the live-only keys an ordinary `registerApp` produces — `data`, `kind`,
+ `package`, `policy` — which sit outside the static contract but are
+ enumerated by `GET /api/v1/meta/types`;
+ - a plugin's own type, which enters the live set as a side effect of
+ registering items of it.
+
+ That is why the rule is the **union** of the two authorities the platform
+ already has — the static predicate the write door consults, and the live
+ listing `GET /meta/types` serves — rather than the static predicate alone.
+ Refusing on the static half alone would answer `400` for types this same
+ service advertises, which is the objection recorded when the write-side verdict
+ landed and was deliberately not raised on the read entries then. Neither list
+ is restated here; both are read from their producers.
+
+ The static verdict runs first and is silent for every accepted spelling, so an
+ ordinary list request pays nothing; the live listing is consulted only by a
+ request already headed for a refusal. If that listing cannot be read — no
+ `getMetaTypes` on the host's protocol, or a rejecting call — the route **fails
+ open** and keeps its prior answer: "no such type" is an existence claim, and
+ stating it while the authority that would know is unreachable is the mistake
+ the write door's own store probe avoids.
+
+ **Scope.** The list door only. The compound arity `/meta/lead/views/all_leads`
+ carries an *object* name in the `:type` segment, which no static contract can
+ enumerate, and is untouched. The single-item doors already refuse
+ distinguishably (`404 RESOURCE_NOT_FOUND`, or `501 NOT_IMPLEMENTED` on the
+ `/references`, `/layers`, `/history`, `/audit`, `/diff`, `/published` limbs), so
+ none of them carried this defect.
+- b6c7690: fix(rest): org-overridable metadata is served back by every `/meta` read door, not just persisted (#9454)
+
+
+
+ A runtime `PUT` of an org-overridable metadata type — `view`, `dashboard`,
+ `report`, `translation`, `email_template` — answered **200** with a receipt
+ reporting `state: 'active'` plus a version and sequence number, **persisted the
+ row with its `organization_id`**, and was then served back by **nothing**: the
+ direct `GET` answered 404, the scoped listing was unchanged, the unfiltered
+ listing was missing it, and the browser rendered an empty view or "Dashboard Not
+ Found". The platform reported success in the same breath as not delivering the
+ work, which is declared ≠ enforced in the direction hardest for an author to
+ notice — the write path says everything worked.
+
+ **The write door was correct as-is.** The row really is persisted, so the
+ receipt is truthful; this was persisted-but-not-served, never a silent write
+ no-op. **The overlay-resolution layer was correct too**, and type-agnostic:
+ `getMetaItem` resolves `(orgId ? findOverlay(orgId) : undefined) ??
+ findOverlay(null)`, `getMetaItems` unions both scopes under org-wins precedence,
+ and `getMetaItemLayered` even reports `overlayScope`. The defect was that the
+ REST read doors **never stated the scope**, so every one of them asked for the
+ env-wide partition and the org partition was never consulted.
+
+ **The repair is one registry-derived predicate, threaded at the read doors.**
+ `organizationIdForMetaRead` joins `organizationIdForMetaWrite` in
+ `metadata-core`, deriving from the same `allowOrgOverride` registry flag, so
+ read scope and write scope cannot drift and a registry entry flipping the flag
+ moves both doors together. It is threaded through the **already-memoised**
+ `resolveExecCtx`, so no new per-request organization resolution is introduced.
+
+ ⛔ **Not a bare `ctx?.tenantId` at each site**, and the reason is measurable
+ rather than stylistic: deployments predating the #6190 ruling hold **phantom
+ org-scoped rows for types the registry declares non-overridable** (the runtime
+ used to stamp `organization_id` on every type). Boot hydration deliberately
+ walks past those rows, so they are dead. A read door naming the org for *every*
+ type would resolve them again — serving, on the read side, a document that
+ vanishes at the next restart.
+
+ **`getMetaItemCached` gains an `organizationId` member** — it was the only meta
+ read verb that could not express one, having hard-coded a two-key delegation to
+ `getMetaItem`. The organization is also folded into its **ETag**. The mechanism
+ differs from `locale` and the difference is stated rather than glossed: `locale`
+ is invisible to the hash (the body is translated after the validator runs), so
+ folding it in was the only way it could vary the validator at all, whereas the
+ org-resolved document *is* the thing hashed. No cache leak is claimed — the
+ directive is `private, no-cache` and there is no server-side cache entry keyed by
+ type+name. It is folded in because that makes scope a **declared** property of
+ the validator instead of an emergent property of the body.
+
+ **Both REST branches are fixed, which is the half-fix this card could easily
+ have shipped instead.** `view` and `dashboard` share one mechanism but reach it
+ through two different arms: `view` takes the cached arm (`getMetaItemCached`),
+ while `dashboard` bypasses the cache via `isDashboardType` and takes the
+ uncached arm. Both omitted the org, so a fix applied to one arm would have
+ fixed exactly one type while the receipt kept claiming success for the other.
+ The scope is now resolved **above** the fork, so the two arms cannot disagree.
+
+ The regression proof drives real REST routes against a real protocol over a stub
+ engine — write-then-read agreement on **one boot**, for all five types, through
+ both arms. Its most important assertions are the ones that do **not** merely
+ check the item comes back: an org-less caller and a **second organization** must
+ each be refused it. An org-blind overlay fallback would satisfy every other
+ assertion in the file while matching an arbitrary tenant's row.
+- e6e1de4: fix(rest): `DELETE /api/v1/packages/:id` answers a driver fault as a 5xx, and stops swallowing coded refusals (#8275)
+
+ `packageService.delete` swallowed every throw and reported failure by returning
+ a bare `{ success: false }`, so the door answered
+ `400 PACKAGE_DELETE_FAILED`. The statement behind it is
+ `DELETE FROM sys_packages WHERE id = ? [AND version = ?]`, so a missing table, a
+ lock timeout or a foreign-key restriction — a **server** fault — was answered as
+ a client error: it invited the caller to fix a request that was never the
+ problem, and it hid a real fault from every dashboard that buckets by status.
+
+ This is the sibling of what #8016 fixed on the throw path and #8131 fixed for
+ `publish`. `service-package` had been left **partially converted** by #8131 —
+ the same service answering two different classifications for the same kind of
+ fault — and this closes that.
+
+ **Two changes, both small:**
+
+ - `delete`'s catch re-throws a throw that **declares its own status**, so a
+ coded refusal reachable from this call path keeps the producer's status and
+ code through the door's #8016 mapping (a `409 DESTRUCTIVE_CHANGE` stays a
+ 409) instead of being flattened into one 400. It reuses the existing
+ `declaresHttpAnswer` predicate rather than declaring a second one.
+ - an undeclared throw stays a returned failure, and the door answers it **500**.
+
+ ⛔ The discriminant is the **status** channel, never `.code`. Every SQL driver
+ populates a string `code` on its errors (`ERR_SQLITE_ERROR`, `SQLITE_ERROR`, the
+ SQLSTATE `42P01`, `ER_NO_SUCH_TABLE`), so a `.code`-reading predicate re-throws
+ genuine driver faults as if they were refusals — resolving them to a `500
+ INTERNAL_ERROR` that carries the driver's own message. Pinned per dialect in
+ `delete-driver-fault.test.ts`, on this seam rather than inherited from
+ `publish`'s suite by analogy.
+
+ **4xx is not swept**, which is the other half of the fix: the
+ repeated-`?version=` refusal is checked before `delete` is called at all,
+ `PACKAGE_DELETE_PARTIAL` keeps its 400 (per-item uninstall failures are a
+ different outcome), a declared 4xx thrown from below keeps its own status and
+ code, and a declared 5xx keeps its own too.
+
+ **No message changed, and that is deliberate.** Unlike `publish`, this path
+ never disclosed anything: the door builds its sentence from the request's own
+ `:id` and `?version=`, and the producer returns a bare flag with **no message
+ channel at all**. Mirroring `publish`'s `driverFault` message here for symmetry
+ would have *created* a channel to the wire that nothing filters — the 5xx
+ withhold (#8086) lives in `sendThrownError`, which a returned failure never
+ reaches at any status. The new suites pin that absence from both sides: the
+ producer's returned shape has exactly one key, and the door answers its own
+ sentence even when handed a producer that grows a message.
+
+ Verified against a real `node:sqlite` database running the real statements from
+ `index.ts` — including a genuine foreign-key restriction, the fault family only
+ `DELETE` can have.
+- 6a12e5e: refactor(rest): `package-routes`' `protocol.getMetaItems` option reads the spec's declared request/response instead of a hand-rolled local shape (#9846)
+
+ `PackageRoutesOptions.protocol` declared its meta-read verb as a local
+ structural type — `getMetaItems?(req: { type: string }): Promise<{ items: any[] }>`
+ — rather than naming the shapes `packages/spec` already declares. Nothing was
+ broken by it: both call sites send exactly `{ type: 'package' }`, which is a
+ valid `GetMetaItemsRequest`, and both read `result?.items` defensively.
+
+ What it was, is the same blindness class one level up from the sibling
+ meta-read doors: a request type *re-stated locally* rather than *read from the
+ spec* lets the contract move underneath this module — a narrowed `type`
+ vocabulary, a newly required member, a renamed key — while the file keeps
+ compiling green against a shape the protocol no longer has.
+
+ Both are now sourced from `@objectstack/spec/api`:
+
+ ```ts
+ getMetaItems?(req: GetMetaItemsRequest): Promise;
+ ```
+
+ **The optionality and the runtime feature-detection are deliberately kept.**
+ `MetadataProtocol` declares `getMetaItems` as a **required** member, while this
+ option is optional and both call sites guard with
+ `typeof … === 'function'`. Adopting `MetadataProtocol` whole would change what
+ the seam tolerates — a behaviour question, deliberately not answered here.
+
+ Naming the declared response surfaced one thing the local `any[]` had been
+ hiding: the spec types `items` as `unknown[]`, because it says nothing about
+ what a metadata item *contains*. The registry-specific keys this module reads
+ off each entry (`manifest.id`) are not spec-declared, so the **element** read
+ stays runtime-shaped on purpose — the same disposition the sibling doors take
+ via `metaItemsArray`. The seam is typed; the element read is coerced at the
+ read and unchanged in behaviour.
+
+ A compile-time pin holds the coupling: an exact type-equality assertion that
+ the option's request/response types are still the spec's, so re-hand-rolling
+ the local shape fails the build rather than passing unnoticed. It lives in
+ compiled source rather than a test file, because this package's `tsconfig.json`
+ excludes its test files and no sibling gate type-checks them — a type-level
+ assertion written there would be compiled by nothing.
+
+ `deletePackage`'s local structural type is untouched: no declared spec shape
+ exists for that verb, and minting one is a contract act rather than a typing
+ cleanup.
+
+ Internal typing only — `PackageRoutesOptions` is not exported from the
+ package's entrypoint, so no public surface changes and no route changes what it
+ accepts or rejects.
+- 2a29caa: Declare the draft-visibility switches on the meta-read request schemas, exactly where the implementation enforces them (#9741, maintainer ruling 2026-08-18): `GetMetaItemsRequestSchema` gains `previewDrafts?: boolean`, and `GetMetaItemRequestSchema` gains `state?: 'active' | 'draft'` plus `previewDrafts?: boolean`. Both members are draft-visibility switches only — declaration ≠ authorization: ADR-0106 masking is unaffected, and draft access stays admin-gated upstream. The cached and layered read requests deliberately declare neither (their implementations enforce neither). `environmentId` stays OUT of the protocol request shape by explicit ruling — it is the transport-level multi-kernel routing key, recorded schema-side as a decision rather than an omission. The REST meta-read doors (list, cached and uncached single-item, layered) drop their `as any` request casts: each request literal now compiles against the declared spec shape, with the transport-level `environmentId` carried by a typed transport envelope (`TransportScopedMetaRequest`) instead of a cast. Accept-set widening catch-up on the declared surface; zero runtime behaviour change.
+- 9e2e682: fix(rest): `/discovery`'s `mcp` advertisement follows the request's environment — `probeMcpServeable` routes through the shared resolution entry point (#9120)
+
+ `RestServer.resolveRequestEnvironmentId` calls itself, in its own doc-comment,
+ "THE single entry point for every unscoped-route environment decision (protocol,
+ i18n, exec-ctx, analytics, …) so they can never disagree about which kernel a
+ request belongs to." Eight consumers go through it. `probeMcpServeable` — the
+ ninth site that needs the request's environment, and the one whose answer decides
+ whether `/discovery` advertises `routes.mcp` — re-derived its own:
+
+ ```ts
+ let environmentId: string | undefined = req?.params?.environmentId;
+ if ((!environmentId || environmentId === ':environmentId') && this.defaultEnvironmentIdProvider) {
+ try { environmentId = this.defaultEnvironmentIdProvider() || undefined; } catch { /* ignore */ }
+ }
+ ```
+
+ That is the shared chain minus its first and middle steps: the host's ADR-0006
+ `kernel-resolver` seam (wired through `RestRequestEnvResolver`), and the legacy
+ hostname / `X-Environment-Id` chain beneath it.
+
+ **Single-environment boots were correct throughout** — there
+ `defaultEnvironmentIdProvider` is registered, and it is also step 3 of the shared
+ chain, so both spellings agreed. The defect is multi-tenant-only: on a
+ hostname-routed host an unscoped `/discovery` request carries no
+ `params.environmentId`, and no default provider is registered (that is
+ `createSingleEnvironmentPlugin`'s wiring). Neither input the probe read was
+ present, so it fell through to `serviceExistsProvider` — which answers for the
+ **host** kernel, not the request's environment. Both misadvertisement directions
+ were reachable, and are now pinned as regression tests:
+
+ - the host kernel has `mcp` and the request's environment does not ⇒ `/discovery`
+ advertised `routes.mcp` for an environment whose `/mcp` answers 501 — the
+ `declared ≠ enforced` shape the probe was added to close;
+ - the host kernel lacks it and the environment has it ⇒ the route was withheld
+ from an environment that would have served it (`mcpServeable !== false` fails
+ open only for a `null` probe, never for a confident `false` computed against
+ the wrong kernel).
+
+ The probe now calls `resolveRequestEnvironmentId` like its eight siblings. The
+ `'platform'` guard and the `serviceExistsProvider` fallback are unchanged, and
+ the unsubstituted `':environmentId'` route pattern is normalised to "no id"
+ before the call — the entry point short-circuits on any truthy explicit value,
+ so passing the pattern through would have sent it to `getOrCreate`. This also
+ makes good the parity the probe's doc-comment already claimed with
+ `resolveRegisteredServices`, whose kernel arrives as `ctx.__kernel` — set
+ downstream of the same entry point.
+- 499f55e: fix(rest): a missing `findReferencesToMeta` capability is refused, not answered as "nothing depends on this item" (#9326)
+
+ `GET /api/v1/meta/:type/:name/references` feature-detects `findReferencesToMeta`
+ on the resolved protocol. When the method was absent the route answered
+ `200 { references: [] }` — so a **capability gap** reached the wire as the
+ statement **"nothing depends on this item"**.
+
+ Per ADR-0110 D3 those are different facts, and here they have opposite
+ consequences. The consumer is the admin "Used by" panel, whose empty state reads,
+ verbatim from `objectui`'s `metadata-admin/i18n.ts`:
+
+ ```
+ 'engine.edit.refsEmptyDesc': 'Nothing in the metadata graph points at this item. Safe to delete.'
+ ```
+
+ An operator about to delete something was shown that sentence on a deployment
+ where the question had never actually been asked.
+
+ The branch now refuses:
+
+ ```
+ 501 { error: { code: 'NOT_IMPLEMENTED',
+ message: 'protocol.findReferencesToMeta() 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.
+
+ **Does any caller's observed response change? Yes, on one deployment shape, and
+ only there.** A protocol that *has* the method is untouched: both an empty and a
+ non-empty result 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 `findReferencesToMeta` 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.
+- 7fc01db: REST `/meta` write doors now carry the caller's organization, so audit rows are no longer stamped environment-wide
+
+ `PUT /meta/:type/:name` (both arities), `DELETE /meta/:type/:name`,
+ `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` passed no
+ organization, so every `sys_metadata_audit` row a REST-authored metadata write produced was
+ stamped `organization_id: null`. Composed with the scoped audit read shipped alongside it —
+ which returns own-org rows **plus** environment-wide ones, a limb that is required rather
+ than optional — that left every REST-authored audit row readable by every tenant, carrying
+ its `actor`, `note`, `lock_state` and `request_id`. The read side could not close this: the
+ rows were genuinely unscoped, so no filter could separate them.
+
+ The organization is taken from the execution context these doors already resolve, and is
+ threaded through `organizationIdForMetaWrite` — the same registry-derived predicate the
+ runtime `/metadata` dispatcher uses. Types the registry declares `allowOrgOverride: true`
+ (`view`, `dashboard`, `report`, `translation`, `email_template`) now scope both the overlay
+ row and its audit row to the caller's organization; every other type continues to write
+ environment-wide, because its write genuinely is environment-wide and the protocol refuses
+ an org-scoped write for it. `null` is now reserved for writes that really are
+ environment-wide.
+
+ Two behaviour changes ride along, both required for the fix to be usable rather than
+ separate improvements: `publish` and `rollback` resolve their row through the organization,
+ so scoping the save without scoping them would have broken the draft → publish loop; and
+ `GET /meta/:type/:name/published` is now organization-scoped (organization-first, then
+ environment-wide), without which it would answer 404 for an item the same caller had just
+ published through the same transport.
+
+ `organizationIdForMetaWrite` / `declaresOrgOverride` moved from `@objectstack/runtime` into
+ `@objectstack/metadata-core` so both doors share one implementation — `@objectstack/rest`
+ cannot import from `runtime`, which depends on it. Runtime behaviour is unchanged.
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2d0af57]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [b6c7690]
+- Updated dependencies [e6e1de4]
+- Updated dependencies [3851f87]
+- Updated dependencies [845e164]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [7fc01db]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/observability@17.1.0
+ - @objectstack/metadata-core@17.1.0
+ - @objectstack/service-package@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/rest/package.json b/packages/rest/package.json
index e5b810ecc1..812ffadfcd 100644
--- a/packages/rest/package.json
+++ b/packages/rest/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/rest",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "ObjectStack REST API Server - automatic REST endpoint generation from protocol",
"type": "module",
diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md
index 939c2d274b..c41675d5a1 100644
--- a/packages/runtime/CHANGELOG.md
+++ b/packages/runtime/CHANGELOG.md
@@ -1,5 +1,1261 @@
# @objectstack/runtime
+## 17.1.0
+
+### Minor Changes
+
+- 2f65b1b: `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`".
+- ca2e020: `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.
+- e43d63a: feat(identity): API keys are minted against the minter's active organization, and carry it into the request (#8287)
+
+
+
+ On a deployment running `OS_TENANCY_POSTURE=isolated`, a minted API key could
+ read **nothing at all**. `sys_api_key` carried no organization column, so key
+ authentication established a user but no active organization — and the
+ `isolated` Layer 0 wall is `organization_id = activeOrganizationId`, which with
+ no active organization matches no row. Every organization-scoped read answered
+ `200` with `total 0` while the console went on offering minting, so a tenant
+ admin could mint a valid-looking secret and discover only at call time that it
+ read nothing. (There was no cross-tenant leak — the failure was in the other
+ direction.)
+
+ **The column was absent by an inherited rule, not by oversight.**
+ `resolveInjectedSystemColumns` injects `organization_id` into every registered
+ object *except* `managedBy: 'better-auth'` ones, and `sys_api_key` carries that
+ flag — even though better-auth's `apiKey` plugin is not loaded and the table is
+ hand-rolled ObjectStack. So the fix needs the declaration *and* the ADR-0105 D7
+ extension-field registration to stay consistent. The read side, by contrast,
+ was **already wired**: `resolveApiKeyPrincipal` already read an organization
+ into `tenantId` and `resolveAuthzContext` already adopted it — it was reading a
+ column no mint path ever wrote.
+
+ **What changes**
+
+ - `sys_api_key` declares `active_organization_id` (+ index, and the column is
+ shown in the "My Keys" and "All" list views, because the card's complaint was
+ a credential whose reach its owner could not see).
+ - `POST /api/v1/keys` **inherits** the caller's active organization — there is
+ deliberately no org parameter and no cross-org key — and **re-checks the
+ caller's `sys_member` membership at mint time**, honouring ADR-0091 validity
+ windows. Under a walled posture it refuses (400) rather than minting a key
+ with no organization, and refuses (403) for an organization the caller is not
+ a member of. The mint response echoes the organization the key is pinned to.
+ - The verifier reads **one spelling** (Prime Directive #12): the
+ `row.organization_id ?? row.organizationId` chain it used to carry was a
+ consumer-side tolerance for a producer that did not exist.
+ - An **ex-member's key fails closed at verify time** — no principal, not a
+ degrade to a user-only principal, which would resurrect the same
+ `200 + total 0` silent-empty. Checked at verify rather than by revoking on
+ membership loss, because membership ends through many paths (better-auth org
+ endpoints, SCIM, a direct `sys_member` delete, a lapsing validity window) and
+ a hook must catch every one or it silently misses. It costs **zero extra
+ queries**: the resolver has already read `sys_member` for this user.
+ - **Pre-existing org-less keys are never backfilled** — that would silently
+ upgrade credentials minted under a different promise. They keep working under
+ `single` (no wall) and under `group` (whose wall derives from the owner's
+ memberships independently of the active organization, so they already work
+ there), and are **refused under `isolated`**, where they are provably dead
+ today.
+
+ **The column is deliberately named `active_organization_id`, not
+ `organization_id`** — the `sys_session` spelling, for the same concept: the
+ organization a credential makes *active*. `objectHasOrgIdField` tests for the
+ literal `organization_id`, and Layer 0 exempts objects without it, so the other
+ name would have made `sys_api_key` itself org-walled. Both walled postures
+ exclude NULL, so every pre-existing org-less row would have vanished from its
+ **own owner's** "My Keys" list while, under `group`, continuing to
+ authenticate — a live credential nobody could see or revoke, which is a fresh
+ instance of the very class this change removes.
+- e374b4d: 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.
+- a433122: **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.
+
+
+- bc6434b: **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.
+
+
+- 96f397a: **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.
+
+
+- 9aa8890: **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.
+
+
+- 48032c9: **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.
+
+
+- 6a51704: 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.
+- b2789ad: 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.
+- 6aceca9: 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`.
+- 20067c5: fix(runtime,mcp,service-datasource): the #6504 consumer sweep — three list consumers stop making claims a known-partial read cannot support (#6504)
+
+
+
+ `IMetadataService.listDiagnosed?(type)` (PR #7721) lets a plural read say whether
+ its answer can be trusted as complete. This is the consumer half: the callers
+ that were restating a possibly-short listing as a fact about the environment.
+
+ Each consumer was qualified individually, per PR #6051's discipline, and most
+ were left alone — a caller publishing a snapshot with no count has nothing to
+ mis-state. Three make a claim, and each now withholds exactly that claim while
+ still serving everything it could read:
+
+ - **`removeDatasource` no longer deletes on a bound-object count it could not
+ take completely.** The guard `if (bound > 0) throw` is the only thing standing
+ in front of an irreversible delete that also unbinds the datasource's secret,
+ and its input is derived from the metadata service's object listing. During a
+ loader outage that listing goes silently short, and the worst value is the
+ benign one: `0` reads exactly like "nothing is bound", so the guard OPENED.
+ It now refuses with `SERVICE_UNAVAILABLE` / 503 — a dependency outage the
+ operator can retry, not a client error — and the record, its credential and
+ its pool all survive.
+ - **The MCP `list_objects` tool stops publishing `totalCount` on a known-partial
+ listing.** This is the same claim PR #7721 removed from the
+ `objectstack://objects` resource, on the other MCP primitive: same payload
+ shape, different door, never covered. A degraded read now serves the same
+ objects with `totalCount` **absent** and `partial` / `returnedCount` /
+ `warning` plus the 503 envelope in its place, so a client reading the total
+ gets `undefined` rather than a believable wrong integer. Both bridges
+ implement it — stdio (`@objectstack/mcp`) and HTTP (`@objectstack/runtime`) —
+ because a completeness claim must not depend on which transport a client
+ connected over.
+ - **The ADR-0015 §5.2 boot gate stops announcing an all-clear over a sweep it
+ could not complete.** It validated whatever `listObjects()` returned and then
+ logged *all federated objects match their remote schema*, with a count.
+ Federated objects behind an unreadable loader were never validated, so
+ `onMismatch: 'fail'` could not have fired for them. The gate now warns that
+ the swept set was incomplete and names what it did validate. ⛔ It does **not**
+ abort boot on a degraded metadata read: turning a transient outage into a
+ refusal to start would be a new failure mode bought with a diagnosis fix.
+
+ Every new member is optional in the same way `listDiagnosed` itself is: a host
+ whose metadata service predates the verdict behaves exactly as it did before,
+ and a service without it reports nothing degraded — precisely what it could
+ express.
+- 5989b0d: feat(runtime): the dispatcher's two discovery bodies join the response envelope (#9813)
+
+
+
+ `GET /.well-known/objectstack` and the REST-less fallback `GET {prefix}/discovery`
+ answered `{ data: {discovery} }` with no `success` flag — one key short of the
+ declared `BaseResponseSchema` envelope. They now answer
+ `{ success: true, data: {discovery} }`.
+
+ This inherits the #9436 maintainer ruling (2026-08-18, option A) on the hono
+ adapter's identical discovery bodies, with its reason intact: machine-read
+ discovery surfaces — SDK `connect()` fallback probes, codegen, AI clients — are
+ the envelope's core constituency, and the migration is one additive key. It is
+ deliberately not #9389's pre-auth exemption, which is a closed list of SPA-read
+ shell-bootstrap surfaces these bodies are not on. Readers that unwrapped
+ `body.data` keep working unchanged; envelope-aware readers that discriminate on
+ `success` now unwrap these routes correctly.
+
+### Patch Changes
+
+- 7c9c1dd: 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.
+- c8e85fc: 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.
+- 3d61924: fix(runtime): a `PUT /meta/:type/:name` with a falsy body is refused instead of being answered as a READ (#8842)
+
+ The http-dispatcher's metadata save branch opened `if (method === 'PUT' && body)`.
+ The `&& body` conjunct was not a guard — it was a hole. Every path inside that
+ block returns (including the terminal `501`), so a falsy body did not merely skip
+ the write: execution continued past the whole save block into the read `try`
+ below, which resolved the type and answered the ordinary metadata **read**.
+
+ A caller who asked to write received what looks like a successful read. No
+ status, header or field distinguished it from a real write acknowledgement —
+ the shape "Absence must be loud" exists to prevent. The `manage_metadata`
+ capability gate, which is the first thing the save branch does, was skipped
+ entirely for such a request as well. (Not an escalation: the request was answered
+ by the read path, which runs the same ADR-0106 mask a plain `GET` runs, and
+ nothing was written. Skipping a write gate on a request that performs no write
+ grants nothing — the defect is the lie, not a privilege.)
+
+ **Reachable from an ordinary client, measured rather than read.** The host that
+ mounts this dispatcher path is the Hono adapter's catch-all, which builds the
+ body as `await c.req.json().catch(() => ({}))`. That `.catch` covers a parse
+ *failure* — an empty body or garbage lands on `{}` — but not a *successful*
+ parse of a falsy JSON value. Driven against a real Hono app, a `PUT` with
+ `content-type: application/json` and a payload of `null`, `false`, `0` or `""`
+ each arrive at the dispatcher falsy.
+
+ **The fix matches the sibling transport rather than inventing a second answer.**
+ `packages/rest`'s `PUT /meta/:type/:name` already folds `req.body ?? {}` and
+ proceeds into the save unconditionally, so its bodyless writes are refused
+ downstream by the per-type schema with `422 INVALID_METADATA`. The dispatcher now
+ does the same: the branch keys off the method alone, and a nullish body folds to
+ `{}`. Two doors onto one `saveMetaItem` disagreeing about what a bodyless
+ metadata write means was the actual defect.
+
+ What callers see instead of a spurious read:
+
+ - holding `manage_metadata` → `422 INVALID_METADATA` from the per-type schema,
+ with the structured `issues` the Studio form reads;
+ - not holding it → `403 PERMISSION_DENIED` from the capability gate, which now
+ runs on this request at all.
+
+ A `PUT` carrying a real body is untouched — it saves exactly as before, and the
+ body still reaches the writer verbatim.
+- 5244fd7: 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.
+- 7ff3975: 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.
+- e783e16: fix(runtime): the HTTP MCP prompt bridge reads the merged skill listing, so a runtime meta PUT finally reaches `/api/v1/mcp` (#8726)
+
+
+
+ `PUT /api/v1/meta/skill/{name}` with `{active:true}` returned 200 and the flip
+ was **not** reflected over MCP prompts. This is the second of the two skill
+ reads behind that symptom, and the one #8328's own three-step reproduction
+ actually runs through.
+
+ The two surfaces read different layers:
+
+ - **stdio** (long-lived server, `packages/mcp` → `bridgePrompts`) — fixed by
+ PR #8724.
+ - **HTTP** `/api/v1/mcp`, built **per request** by `packages/runtime`
+ (`domains/mcp.ts` → `buildMcpBridge.listSkills`) — this change. It read
+ `metadataService.list('skill')`, the registry/loader listing, one layer
+ **below** where any `sys_metadata` overlay merging happens. So the overlay row
+ the PUT wrote was never seen, while `GET /api/v1/meta/skill` served it
+ correctly from the merged read: two surfaces, one skill name, two answers.
+
+ The read now goes through the protocol layer's `getMetaItems`, per the
+ maintainer's ruling on #8328 (2026-08-13, option 3) — and ⛔ **not** by pushing
+ the overlay merge down into `MetadataService.list()` for every consumer, which
+ is a wider contract change archived unscheduled as #8722.
+
+ **Resolved per request, on the same per-environment seam `getMeta()` already
+ uses** — never captured once at boot, which on a multi-tenant host would serve
+ one environment's overlay rows to every other one. Pinned by two
+ multi-environment tests.
+
+ **⛔ No fallback to the un-merged listing when the merged read throws.** That
+ would answer registry rows in the shape of merged ones — this exact defect,
+ restored silently at the moment the overlay store is unreadable, which is
+ precisely when an overlay is most likely to be the thing being missed. The
+ throw travels to the MCP client instead. Structural absence is treated as the
+ different thing it is: a host assembled without the metadata protocol has no
+ merged read to offer, so it keeps the registry listing unchanged, including the
+ load-bearing `?? []` for a host with no metadata service at all.
+
+ **#6504's completeness verdict is added here rather than preserved** — unlike
+ the stdio bridge, this read never had a diagnosed wrapper, so a known-partial
+ skill surface presented as a complete one. The verdict is asked of
+ `IMetadataService.listDiagnosed` directly rather than taken from the merged
+ read, because `getMetaItems` swallows a MetadataService read failure into its
+ own `catch` and reports a merged list either way. It is reported at `warn`
+ (functional degradation: the prompt surface is visibly smaller than the
+ environment declares), and a verdict probe that itself fails is reported as
+ "could not be determined" rather than failing a read whose items succeeded.
+- 4fc4a3c: **`DELETE` / `PATCH` / `POST` on the dispatcher's `/metadata/:type/:name` are refused with `405` instead of being answered as reads.**
+
+ The `parts.length >= 2` block carried exactly one method-sensitive branch — the `PUT` save — and the read that followed it had no method guard, so every other verb fell into it and was served the ordinary metadata read. `DELETE` was the sharpest case: a caller asking to delete a metadata item received `200` plus the item document, which is indistinguishable from a successful destructive call, while nothing was deleted and `protocol.deleteMetaItem` was never invoked. No status, header or field separated any of those answers from a real `GET`.
+
+ The block now answers `405 METHOD_NOT_ALLOWED` with an `Allow: GET, HEAD, PUT` header naming what it serves, aligning it with every other route in the same file (which already guard their verb). `GET`, `HEAD` and `PUT` are unchanged, and a request that passes no method still defaults to the read.
+
+ Note this narrows an accepted surface: a client that was relying on `DELETE`/`PATCH`/`POST` returning the document now gets a `405`. It never performed the operation the verb named — use `GET` to read, or `packages/rest`'s `DELETE /api/v1/meta/:type/:name` for a real metadata delete.
+- 7fc01db: REST `/meta` write doors now carry the caller's organization, so audit rows are no longer stamped environment-wide
+
+ `PUT /meta/:type/:name` (both arities), `DELETE /meta/:type/:name`,
+ `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` passed no
+ organization, so every `sys_metadata_audit` row a REST-authored metadata write produced was
+ stamped `organization_id: null`. Composed with the scoped audit read shipped alongside it —
+ which returns own-org rows **plus** environment-wide ones, a limb that is required rather
+ than optional — that left every REST-authored audit row readable by every tenant, carrying
+ its `actor`, `note`, `lock_state` and `request_id`. The read side could not close this: the
+ rows were genuinely unscoped, so no filter could separate them.
+
+ The organization is taken from the execution context these doors already resolve, and is
+ threaded through `organizationIdForMetaWrite` — the same registry-derived predicate the
+ runtime `/metadata` dispatcher uses. Types the registry declares `allowOrgOverride: true`
+ (`view`, `dashboard`, `report`, `translation`, `email_template`) now scope both the overlay
+ row and its audit row to the caller's organization; every other type continues to write
+ environment-wide, because its write genuinely is environment-wide and the protocol refuses
+ an org-scoped write for it. `null` is now reserved for writes that really are
+ environment-wide.
+
+ Two behaviour changes ride along, both required for the fix to be usable rather than
+ separate improvements: `publish` and `rollback` resolve their row through the organization,
+ so scoping the save without scoping them would have broken the draft → publish loop; and
+ `GET /meta/:type/:name/published` is now organization-scoped (organization-first, then
+ environment-wide), without which it would answer 404 for an item the same caller had just
+ published through the same transport.
+
+ `organizationIdForMetaWrite` / `declaresOrgOverride` moved from `@objectstack/runtime` into
+ `@objectstack/metadata-core` so both doors share one implementation — `@objectstack/rest`
+ cannot import from `runtime`, which depends on it. Runtime behaviour is unchanged.
+- c86799f: fix(service-automation): a retry attempt that PAUSES is a durable pause, not a failed attempt — `executeWithoutRetry` gets the ADR-0019 suspend arm (#9510)
+
+ `execute()`'s catch tests the suspend signal FIRST, and that arm is what makes
+ ADR-0019's durable pause work: it snapshots the live variables, calls
+ `persistSuspendedRun`, records a `paused` log entry and returns
+ `{ success: true, status: 'paused', runId }`.
+
+ `executeWithoutRetry()` — the method `retryExecution` re-runs the flow through on
+ **every** retry attempt — had no such arm. A `FlowSuspendSignal` thrown on a
+ retry attempt fell into the generic failure path, and four things were lost at
+ once:
+
+ 1. `persistSuspendedRun` never ran, so **the continuation was never stored** and
+ the run could not be resumed by anyone, ever;
+ 2. the run log recorded `failed` for a run that asked to pause;
+ 3. the caller got `status: 'failed'`, with the suspend signal stringified into
+ `error` (`FlowSuspendSignal` is not an `Error`);
+ 4. `retryExecution` reads only `result.success`, so the pause counted as one more
+ failed attempt: the loop burned the rest of the budget, and every further
+ attempt re-entered the pausing node and orphaned another suspension.
+
+ Only a LATER attempt is exposed — `execute()` handles the first one correctly,
+ and a flow reaches `retryExecution` only after a failure. The reachable shape is
+ the ordinary one: `errorHandling.strategy: 'retry'` on a flow whose flaky
+ HTTP/connector call is followed by an `approval` or `screen` node.
+
+ **⚠️ Runs already lost to this defect are NOT recoverable.** Nothing was written
+ for them — no `sys_automation_run` row, no in-memory suspension — so there is no
+ continuation to rehydrate and no repair, here or later, can bring one back. The
+ run log holds a `failed` entry naming the flow and the trigger; those runs have
+ to be triggered again. What this change fixes is every run from here on.
+
+ **The repair is a restoration of a stated contract on a path that never got it,
+ not a new capability.** `AutomationResult.status: 'paused'` and ADR-0019 already
+ describe exactly this behaviour, and `execute()`'s own arm already implements it;
+ the retry path simply never received it. The alternative — refusing
+ `strategy: 'retry'` combined with a pausing node at authoring time — was
+ considered and rejected: it over-refuses (a pausing node can sit on a branch the
+ retrying path never reaches), under-refuses (a pausing node behind a runtime
+ condition is not statically decidable), and would ban the one combination authors
+ most reasonably reach for.
+
+ **The cost, and what was done about it.** Lifting the arm makes `retryExecution`
+ able to return a NON-TERMINAL result, and both of its readers were taught the
+ third state explicitly rather than left to a branch that happens to fall through:
+ the retry loop returns a paused attempt because it PAUSED (tested on `status`,
+ before the `success` check that means "this attempt succeeded"), and the trigger
+ route answers it from its own arm. The retry accounting is untouched — a
+ genuinely failing attempt still consumes one, `maxRetries` still bounds the loop,
+ and the loop stops only because the attempt did not fail.
+
+ **Both routes give one answer**, pinned as an equality rather than verified in
+ isolation: a pause on attempt 1 and a pause on attempt 3 produce the same engine
+ result and the same wire response, so no caller can tell which attempt paused.
+
+ Two adjacent gaps were measured out of this work and filed rather than absorbed:
+ a retry attempt runs with a smaller variable environment than the first (#9704),
+ and a flow's declared retry policy stops applying once a run pauses (#9705) —
+ the latter being the measured answer to "what happens to the retry budget when a
+ paused run is resumed and then fails": neither inherited nor fresh, because the
+ resume path has no retry loop at all. Both are pinned as today's behaviour so
+ neither can change by accident.
+- 19db5fa: fix(runtime): `publish-drafts` no longer discloses driver or subscriber text on `unhideError` / `rebindError` (#8516)
+
+ `POST /api/v1/packages/:id/publish-drafts` answered, on a **200**:
+
+ ```json
+ { "success": true, "data": {
+ "unhideError": "SQLITE_ERROR: no such table: sys_metadata",
+ "rebindError": "TypeError: Cannot read properties of undefined (reading 'triggers') at AutomationPlugin.rebind (/srv/objectstack/packages/services/service-automation/dist/index.js:412:31)" } }
+ ```
+
+ These are the two remaining producers on the response whose `seedApplied` field
+ #8443 converted — the ADR-0045 visibility flip and the `metadata:reloaded`
+ announce. Both ride a success body as **data**, so no HTTP boundary's 5xx
+ message withhold can reach them; the disclosure had to be closed at the
+ producer. Both were driven for real before being changed, and both reproduced.
+
+ Both now follow the rule already in force next door: a caught sentence is
+ quoted only when the error **declared** itself a client-facing refusal (4xx
+ `status`, ADR-0112); anything else gets the stable sentence the field could
+ already carry, and the original goes to the server log. The rule is imported
+ from `@objectstack/metadata-protocol` (`clientFacingFailureText`), not restated
+ locally.
+
+ **Both halves of the rule, because the two sites started in different states.**
+ The flip already logged its cause in full at `error` with an operator remedy, so
+ only its payload changed. The announce had **no log line at all** — withholding
+ alone would have converted an over-disclosure into a silent failure, so it gains
+ one at `warn`, naming the cause, the concrete consequence (a newly published
+ record-triggered flow does not bind its trigger until the process restarts) and
+ the fix (re-run the idempotent publish, or restart). `warn` rather than `error`
+ because nothing that claimed to persist failed to: the drafts are published and
+ the flip is stored, and an unbound trigger is AGENTS.md's own worked example of
+ a functional degradation — the level the sibling announce of this same event
+ already uses.
+
+ **Authoring feedback is preserved, not blanked.** The flip's authored refusals
+ all declare 4xx (`ITEM_LOCKED`, `NOT_OVERRIDABLE`,
+ `OBJECT_OVERLAY_PACKAGE_MISMATCH`, …), so a locked or non-overridable app still
+ tells its publisher which app and why, verbatim — and the `unhiddenApps`
+ half-flip report beside it is untouched. A subscriber that declares a 4xx
+ refusal is quoted by the same positive list.
+- 2b9d33a: Stamp seeded rows with the install's organization so one object runs one autonumber scope (#8686)
+
+ Seed writes and API writes disagreed about tenancy. Seed data is loaded during
+ app start, before any human user exists, so the seed loader had no organization
+ to stamp and its rows landed `organization_id = NULL`; API writes carried the
+ signed-in user's organization. The SQL driver keys its autonumber counter by
+ exactly that column (`__global__` when NULL), so a single object ran two
+ independent counters — and the uniqueness index is partitioned by the same key
+ (`COALESCE(organization_id, '__global__'), `), so the duplicates the
+ second counter minted were invisible to the constraint. On a single-tenant
+ install seeded with `CASE-00001..38`, the first four API creates returned
+ `CASE-00001..4` again: four duplicated values on a field declared `unique`, with
+ 201s and no warning.
+
+ Seed writes now carry the organization the same way API writes do. The moment an
+ install's organization first exists, untenanted seed rows are adopted into it and
+ the `__global__` counter is merged into the organization-scoped one, so the
+ `__global__` pseudo-tenant stops acting as a peer of a real organization. Existing
+ installs are repaired by a one-shot boot-time backfill, guarded to single-tenant
+ installs; a multi-tenant install where a split is detected is never guessed at —
+ the backfill skips and logs the condition and the remedy. Business identifiers
+ that were already minted twice are reported for the operator, never silently
+ renumbered. Platform namespaces (`sys_`/`cloud_`/`ai_`) stay global, exactly as
+ the seed loader already treats them.
+- ad217b1: fix(metadata-protocol): compile the seed-tenancy backfill's statements for the connected dialect, so they run on MySQL (#9381)
+
+ `seed-tenancy-backfill.ts` quoted every identifier the ANSI way (`"x"`) on every
+ dialect. MySQL does not run with `ANSI_QUOTES` — measured on a live MySQL 8.0.46,
+ whose `sql_mode` is
+ `ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION`,
+ and nothing in `driver-sql` sets one — so `"x"` is a string literal there and all
+ seven statements failed with `ER_PARSE_ERROR`. The repair for #8686 therefore
+ never ran on MySQL, silently: a migration must not fail a boot, so every call site
+ turns the failure into a warning and the symptom was a skipped repair in the log
+ rather than an error.
+
+ The statements are now compiled for the driver actually connected, and the seam
+ carries the dialect with it (`resolveSeedTenancySeam` returns `{ exec, client }`;
+ `backfillSeedTenancy` takes that pair) so a caller cannot lose it. Two further
+ MySQL-only defects in the same statements, both measured on the same server, are
+ fixed with it: `last_value` is a reserved word on MySQL 8.0 and is now quoted
+ wherever it is unqualified, and the stamp's exclusion sub-SELECTs go through a
+ derived table because MySQL refuses `UPDATE t … (SELECT … FROM t)` with
+ `ER_UPDATE_TABLE_USED`. SQLite and PostgreSQL keep the exact ANSI spelling they
+ had (both re-verified live).
+
+ `resolveSeedTenancyExec` stays exported and unchanged for callers that resolve the
+ dialect themselves; `backfillSeedTenancy` now takes the seam object instead of a
+ bare exec.
+- 593c4bf: feat(spec): `storage` becomes the canonical `CoreServiceName` slot; `file-storage` stays a deprecated v17 alias (#9683)
+
+
+
+ Maintainer ruling, 2026-08-18, verbatim: 「9683 file-storage 可以叫 storage」.
+ The `file-storage` slot was the only `CoreServiceName` member whose spelling
+ diverged from its documented accessor (`services.storage`), with no recorded
+ reason anywhere in the tree.
+
+ - `CoreServiceName` gains `storage` as the canonical member; `file-storage`
+ stays an accepted, deprecated alias within v17 (it is a published enum
+ member — existing `getService('file-storage')` callers keep working).
+ `CORE_SERVICE_PROVIDER` and `ServiceRequirementDef` carry both.
+ - `@objectstack/service-storage` registers the **same instance** under both
+ names (the `http.server` / `http-server` pattern), pinned by an
+ alias-equivalence test.
+ - Every internal consumer resolves `storage`: the HTTP dispatcher, the email
+ plugin's attachment store, and `os migrate files-to-references`. Discovery
+ reports the service under the canonical `storage` key and mirrors the row
+ verbatim under the `file-storage` key for the alias's v17 lifetime, so
+ existing discovery readers (e.g. the console endpoint catalog) keep
+ working.
+ - Docs (`kernel/runtime-services`, `kernel/contracts`) now document the
+ canonical slot; a custom v17 provider for this slot should register both
+ names.
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [e7bccaa]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [e374b4d]
+- Updated dependencies [5047cb8]
+- Updated dependencies [ed4ca59]
+- Updated dependencies [445ae4d]
+- Updated dependencies [9aa8890]
+- Updated dependencies [40d5b2d]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [8bbf459]
+- Updated dependencies [a751f7d]
+- Updated dependencies [eccb8b2]
+- Updated dependencies [650cd3d]
+- Updated dependencies [b735507]
+- Updated dependencies [91c6c28]
+- Updated dependencies [75b7c24]
+- Updated dependencies [cf0d902]
+- Updated dependencies [498f4e8]
+- Updated dependencies [cc5c07b]
+- Updated dependencies [d9813a9]
+- Updated dependencies [4c178c1]
+- Updated dependencies [13d7864]
+- Updated dependencies [8640fb2]
+- Updated dependencies [5c38492]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [3508678]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2d0af57]
+- Updated dependencies [2c570f3]
+- Updated dependencies [7337f30]
+- Updated dependencies [420804d]
+- Updated dependencies [8656d67]
+- Updated dependencies [177442d]
+- Updated dependencies [950bd94]
+- Updated dependencies [3043e98]
+- Updated dependencies [51a46a4]
+- Updated dependencies [cbf4b40]
+- Updated dependencies [9c4d096]
+- Updated dependencies [86431f7]
+- Updated dependencies [716ac9b]
+- Updated dependencies [e9534a4]
+- Updated dependencies [7b3c033]
+- Updated dependencies [6feac91]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [4ea921c]
+- Updated dependencies [0ccea4a]
+- Updated dependencies [3ab2488]
+- Updated dependencies [2b292ce]
+- Updated dependencies [185c7bd]
+- Updated dependencies [abcf853]
+- Updated dependencies [14935ab]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [66dbec4]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [45862a5]
+- Updated dependencies [7ff3975]
+- Updated dependencies [fd6bdf8]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [a9df51c]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [ab8b10f]
+- Updated dependencies [4e71ae1]
+- Updated dependencies [739fe5b]
+- Updated dependencies [20067c5]
+- Updated dependencies [bc03179]
+- Updated dependencies [5ed8ee6]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [b537855]
+- Updated dependencies [2065e31]
+- Updated dependencies [ead96d0]
+- Updated dependencies [6cb88d9]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4dc8a61]
+- Updated dependencies [c15eb23]
+- Updated dependencies [4d47afe]
+- Updated dependencies [2a9752c]
+- Updated dependencies [b740440]
+- Updated dependencies [90a12fb]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [72050cc]
+- Updated dependencies [d70428a]
+- Updated dependencies [4dfa369]
+- Updated dependencies [9a56784]
+- Updated dependencies [c8806ae]
+- Updated dependencies [bb96297]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [5e2f594]
+- Updated dependencies [3b3f67d]
+- Updated dependencies [e2899f6]
+- Updated dependencies [b6c7690]
+- Updated dependencies [855591f]
+- Updated dependencies [e6e1de4]
+- Updated dependencies [6a12e5e]
+- Updated dependencies [3851f87]
+- Updated dependencies [c73eacd]
+- Updated dependencies [712e185]
+- Updated dependencies [693c788]
+- Updated dependencies [0961065]
+- Updated dependencies [845e164]
+- Updated dependencies [2a29caa]
+- Updated dependencies [9e2e682]
+- Updated dependencies [09a6eee]
+- Updated dependencies [8d017eb]
+- Updated dependencies [1a7f907]
+- Updated dependencies [0425db9]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [05864fb]
+- Updated dependencies [4e3a4c3]
+- Updated dependencies [3b0b61c]
+- Updated dependencies [326f5de]
+- Updated dependencies [30d3752]
+- Updated dependencies [8914915]
+- Updated dependencies [21995d7]
+- Updated dependencies [a4c11ad]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [499f55e]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [6a5e6ad]
+- Updated dependencies [30b1c63]
+- Updated dependencies [7fc01db]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [2416dd5]
+- Updated dependencies [88ef34d]
+- Updated dependencies [add2d19]
+- Updated dependencies [5d4d20e]
+- Updated dependencies [2b9d33a]
+- Updated dependencies [ad217b1]
+- Updated dependencies [f01c0ee]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [b2a451f]
+- Updated dependencies [c25b2d5]
+- Updated dependencies [147eadc]
+- Updated dependencies [0f59584]
+- Updated dependencies [f6c904a]
+- Updated dependencies [ff08691]
+- Updated dependencies [159e299]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [7c2f386]
+- Updated dependencies [56bca91]
+- Updated dependencies [52fbba6]
+- Updated dependencies [d5156b9]
+- Updated dependencies [75e66fc]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [8a9e7f4]
+- Updated dependencies [3d0ded8]
+- Updated dependencies [a726154]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [1258dca]
+- Updated dependencies [91c4ff5]
+- Updated dependencies [a4acb8d]
+- Updated dependencies [d634e66]
+- Updated dependencies [682b86b]
+- Updated dependencies [6a1b45e]
+ - @objectstack/spec@17.1.0
+ - @objectstack/plugin-auth@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/plugin-security@17.1.0
+ - @objectstack/rest@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/metadata-protocol@17.1.0
+ - @objectstack/objectql@17.1.0
+ - @objectstack/driver-sql@17.1.0
+ - @objectstack/service-datasource@17.1.0
+ - @objectstack/driver-memory@17.1.0
+ - @objectstack/driver-sqlite-wasm@17.1.0
+ - @objectstack/metadata@17.1.0
+ - @objectstack/observability@17.1.0
+ - @objectstack/metadata-core@17.1.0
+ - @objectstack/service-i18n@17.1.0
+ - @objectstack/formula@17.1.0
+ - @objectstack/service-cluster@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/runtime/package.json b/packages/runtime/package.json
index 183357b674..293a47af3e 100644
--- a/packages/runtime/package.json
+++ b/packages/runtime/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/runtime",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "ObjectStack Core Runtime & Query Engine",
"type": "module",
diff --git a/packages/sdui-parser/CHANGELOG.md b/packages/sdui-parser/CHANGELOG.md
index 77586671e7..411287dfda 100644
--- a/packages/sdui-parser/CHANGELOG.md
+++ b/packages/sdui-parser/CHANGELOG.md
@@ -1,5 +1,7 @@
# @objectstack/sdui-parser
+## 17.1.0
+
## 17.0.0
### Patch Changes
diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json
index 538c4444c5..9a7e619452 100644
--- a/packages/sdui-parser/package.json
+++ b/packages/sdui-parser/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/sdui-parser",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "ObjectStack constrained JSX-source → SDUI SchemaNode tree compiler (parse, never execute). Isomorphic, zero React. ADR-0080.",
"main": "dist/index.js",
diff --git a/packages/services/service-analytics/CHANGELOG.md b/packages/services/service-analytics/CHANGELOG.md
index c9a64ce6ee..def7f86340 100644
--- a/packages/services/service-analytics/CHANGELOG.md
+++ b/packages/services/service-analytics/CHANGELOG.md
@@ -1,5 +1,246 @@
# Changelog — @objectstack/service-analytics
+## 17.1.0
+
+### Patch Changes
+
+- d09d0fd: Source the comparand-type allow-list and the accepted-set refusal sentence from the shared `@objectstack/spec/data` door instead of re-spelling them locally.
+
+ `comparand-shape.ts`'s `isBindableComparand` / `isRenderableTextComparand` spelled the same six accepted comparand types (`string | number | bigint | boolean | null | Date`) that `isAcceptedFilterComparand` single-sources for the SQL driver family, and two refusal messages hand-copied the accepted-set sentence. Both predicates now delegate the type membership to the door and quote `ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE`, matching how `driver-sql` and `driver-turso` consume it.
+
+ No comparand is accepted or refused differently: the local copies already agreed with the door, and the full accept/refuse matrix is pinned end to end at both analytics filter doors, in three comparand positions each, measured before the change and re-run unchanged after it.
+
+ One user-visible wording correction falls out of removing the copy: the hand-copied sentence omitted `bigint`, a type both predicates have always accepted and both doors have always compiled, so a refusal message under-described the values it accepts. The message now names the full set. The package-local extras — a binary bindable, and the `undefined` arm both doors already refuse upstream — are unchanged and recorded at their use sites.
+- 0425db9: Published READMEs link to the docs site in the one form that works on npm, on GitHub and on the docs site (#9632)
+
+ **Seven docs links in these READMEs pointed nowhere.** They were spelled as a repo
+ path rooted at `/` — `[Flows](/content/docs/automation/flows.mdx)` — and a README in a
+ package's `files` array with `private` unset is rendered on the **npm package page** and
+ on **GitHub**, not only in this repository. There a root-relative href resolves against
+ `npmjs.com` and `github.com` respectively. It was not a docs-site route either:
+ `apps/docs/lib/source.ts` mounts `loader({ baseUrl: '/docs' })` over `content/docs`, so
+ the route for that first link is `/docs/automation/flows`, and `apps/docs/redirects.mjs`
+ carries no `/content` source that would rescue the written form. Every target page
+ existed and every one of them was reachable — only the links were not.
+
+ All seven now use the absolute form the repo had already established in
+ `create-objectstack`'s published READMEs: `https://docs.objectstack.ai/docs/...`, with
+ the path taken under `content/docs` and the page extension dropped, because the route
+ carries none. Each target was re-verified at the route level rather than as a file — the
+ two that named a **directory** (`/content/docs/automation/`,
+ `/content/docs/references/automation/`) resolve only because those directories carry an
+ `index.mdx`; a directory without one is a 404, not a section.
+
+ **Two more links in the same class were converted in the same pass.**
+ `service-knowledge` and `knowledge-ragflow` pointed at
+ `../../../content/docs/protocol/knowledge.mdx`. Those relative paths do resolve on both
+ GitHub and npm, so they are a milder defect than the seven — but they land the reader on
+ **raw MDX source** instead of the rendered page. They now point at the rendered page as
+ well. `service-knowledge`'s link text changed with it: it was the source filename in a
+ code span, which stops being an honest label once the destination is the page.
+
+ No API, behaviour or type surface changes — this is the published documentation these
+ packages ship.
+- f01c0ee: docs: five published service READMEs stop documenting an API that does not exist (#9532)
+
+ A version bump is the point, not a side effect: these five READMEs are in their
+ packages' `files` arrays with `private` unset, so they are the pages npm renders —
+ and a docs-only fix with no bump never reaches npm at all.
+
+ Each of the five told a reader to an import of a `Service…` class from its own package
+ and call a static `.configure({...})` on it. Neither has ever existed: no class in
+ this repo exposes a static `configure`, and none of `ServiceAnalytics`,
+ `ServiceAutomation`, `ServiceCache`, `ServiceI18n` or `ServiceJob` is exported by
+ anything. A reader following any of them wrote code that could not compile. The real
+ entry point in every case is a kernel plugin constructed with `new`:
+ `AnalyticsServicePlugin`, `AutomationServicePlugin`, `CacheServicePlugin`,
+ `I18nServicePlugin`, `JobServicePlugin`.
+
+ ⛔ A name swap alone would not have been enough, and the gate landed in #9546 is what
+ proves it: substituting the genuine class while keeping `.configure(...)` turns the
+ import finding into a call-site finding rather than into silence. Each README is
+ rewritten against the package's built type surface, and each package's entry is
+ deleted from `scripts/published-readme-exports.baseline.json` in the same change
+ (the baseline is reconciled in both directions, so a stale entry fails too).
+
+ What was removed as fabricated, beyond the entry point:
+
+ - **service-analytics** — a nine-endpoint REST surface (`/analytics/count`, `/sum`,
+ `/avg`, `/min`, `/max`, `/group-by`, `/time-series`, `/metrics`, `/metrics/:name`)
+ of which none exists; the real surface is `POST /analytics/query`,
+ `GET /analytics/meta`, `POST /analytics/sql` and `POST /analytics/dataset/query`.
+ Also removed: `defineMetric`, `getMetric`, `compare`, `funnel`,
+ `executeDashboard`, `invalidateCache`, and an `AnalyticsServiceConfig` block whose
+ four keys (`defaultDriver`, `enableCaching`, `cacheTTL`, `maxMemoryResults`) are
+ none of the real ones.
+ - **service-automation** — `executeFlow`/`getFlow`/`listFlows`/`getFlowHistory`/
+ `registerTrigger` as the contract (the real contract is `execute(flowName, context?)`
+ plus `listFlows()` and a set of optional members), and a five-endpoint REST list that
+ matches no mounted route. The flow-authoring half of that README was already accurate
+ and is kept.
+ - **service-cache** — `mget`/`mset`/`del`/`delPattern`/`namespace`/`ttl`/`expire`/
+ `persist`/`incr`/`incrby`/`decr`/`getOrSet`/`invalidateTag`/`resetStats`, none of
+ which exist; `ICacheService` has six members. `CacheStats.keys`/`hitRate` corrected to
+ `keyCount` (there is no `hitRate`), and `set(key, value, { ttl })` corrected to the
+ real positional `set(key, value, ttl?)` in seconds.
+ - **service-i18n** — an `await i18n.t('ns:key')` dialect with namespaces, plural
+ suffixes, `context`, `returnObjects`, `setLocale`/`getLocale`, `formatDate`/
+ `formatNumber`/`formatRelative`, `addLocale`/`removeLocale`/`reload`, `getCoverage`/
+ `getMissingKeys`, and a `{{lng}}/{{ns}}` file layout. The real `t()` is synchronous
+ and takes the locale positionally — `t(key, locale, params?)` — over one
+ `{locale}.json` file per locale. The `POST /i18n/translate` endpoint does not exist.
+ - **service-job** — `scheduleInterval`/`scheduleOnce`/`getJob`/`stopJob`/`resumeJob`/
+ `deleteJob`/`runNow`/`getJobHistory`/`clearHistory`/`getLastExecution`, and a
+ `schedule({ name, schedule, handler })` options-object call. The real `schedule` is
+ positional — `schedule(name, schedule, handler, options?)` — and returns `void`.
+ Retry defaults corrected to the enforced ones (`maxRetries: 0`,
+ `backoffMultiplier: 1`).
+
+ Two capability claims are corrected rather than deleted, because the source is what
+ decides:
+
+ - **service-cache** advertised Redis as production support. `RedisCacheAdapter` throws
+ `RedisCacheAdapter not yet implemented` from every method, and
+ `new CacheServicePlugin({ adapter: 'redis' })` throws during `init` rather than
+ falling back to memory. The README now says so at the top and points at registering
+ a custom `ICacheService` under the slot instead.
+ - **service-job**'s `adapter: 'interval'` stores cron registrations that never fire.
+ That is now stated in the adapter table rather than left for a reader to discover.
+
+ No compliance claim (SOC 2 / HIPAA / GDPR or similar) was found in any of the five —
+ the shape that raised `plugin-audit`'s severity in #9517 is absent here.
+- 402c125: fix(objectql): a temporal filter comparand the platform cannot interpret is refused at the engine door instead of answering 200 with zero rows (#8690)
+
+
+
+ A `datetime` / `date` / `time` field filtered with a bare string the platform
+ cannot read — `last_30_days`, `not-a-date-at-all` — was bound **as written**
+ all the way to the driver, where the comparison is false for every row. The
+ caller received `HTTP 200`, an empty result set, and nothing to indicate the
+ filter was meaningless. An unknown `{placeholder}` in the same position was
+ already refused loudly (`FILTER_TOKEN_UNKNOWN` / 400, listing the resolvable
+ tokens), so one API answered two shapes of unusable comparand two different
+ ways.
+
+ It is concretely reachable rather than theoretical: `last_7_days` /
+ `last_30_days` / `last_90_days` are **declared preset names** in the dashboard
+ schema. The shipped console lowers them to `{N_days_ago}` macros before they
+ reach the API, so the console path was always safe — but a saved report, an
+ integration, an MCP client or an AI-authored query sends the preset name itself
+ and got a silent zero. An empty chart is the hardest failure to debug: it is
+ indistinguishable from "there is genuinely no data".
+
+ Such a comparand is now refused at the ObjectQL engine's single filter
+ collection point, with `code: 'INVALID_FILTER'` and `status: 400`, naming the
+ field, the value, the key path and the spellings that would work. That seam is
+ the one place holding the caller's comparand and the field's **declared type**
+ at the same moment, and every verb (`find` / `findOne` / `count` / `aggregate`
+ / `update` / `delete`) and both filter spellings (the array sugar and the
+ lowered condition) pass through it, so all four backends inherit one answer
+ rather than four. `NativeSQLStrategy` additionally **declines** such a query so
+ the raw-SQL analytics path falls through to that door instead of binding the
+ value into its own statement.
+
+ Deliberately unchanged, each by ruling: a `{placeholder}` keeps its existing
+ refusal one layer down (the door runs before token resolution and steps around
+ them, so `{30_days_ago}` still resolves normally); non-string comparands are
+ untouched (a number is epoch milliseconds, a `Date` is an instant); and the
+ **empty string** keeps today's behaviour exactly — it binds as `''` and matches
+ every non-null row, which is a separate question that remains its own card.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2d0af57]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json
index fbad75f0f2..c3a4f21276 100644
--- a/packages/services/service-analytics/package.json
+++ b/packages/services/service-analytics/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-analytics",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Analytics Service for ObjectStack — implements IAnalyticsService with multi-driver strategy pattern (NativeSQL, ObjectQL, InMemory)",
"type": "module",
diff --git a/packages/services/service-automation/CHANGELOG.md b/packages/services/service-automation/CHANGELOG.md
index 8f6695c29d..66968aa299 100644
--- a/packages/services/service-automation/CHANGELOG.md
+++ b/packages/services/service-automation/CHANGELOG.md
@@ -1,5 +1,684 @@
# @objectstack/service-automation
+## 17.1.0
+
+### Minor Changes
+
+- bc6434b: **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.
+
+
+- 9aa8890: **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.
+
+
+- 48032c9: **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.
+
+
+- d31785f: feat(automation): flow `notify` nodes can reference an email template for localized delivery — `template` + `templateData` on `NotifyNodeConfig`, resolved by `(name, recipient locale)` at delivery time (#9205)
+
+ Ruled 「立项,走 emailTemplates 路线」: instead of widening the `flows`
+ translation surface (whose guidance excludes notification text, #7646), a
+ `notify` node now bridges to the existing localized email-template subsystem.
+
+ - **Spec** — `NotifyConfigSchema` gains `template` (a `sys_email_template`
+ name, read raw like `topic`/`channels`) and `templateData` (render context
+ for the template's `{{var}}` holes; values interpolate `{token}` templates
+ per run) as the localizable alternative to inline `title`/`message`. Inline
+ strings stay fully valid and byte-identical for existing flows — they are
+ the non-localizable path, and the describes now say so. A node carrying BOTH
+ paths, or `templateData` without `template`, or NEITHER path, is refused
+ loudly with the fix in the message (the `objectNavTargetExclusivity`
+ posture: unrepresentable over silent precedence).
+ - **service-automation** — the notify executor forwards the template
+ reference and its interpolated render context in the emit payload (the
+ outbox snapshots it onto each delivery row), and no longer demands an
+ inline title when a template is referenced.
+ - **service-messaging** — the email channel routes a template-carrying
+ delivery through `IEmailService.sendTemplate({ template, locale, data })`,
+ resolving the recipient locale per delivery: `payload.locale` if the
+ producer set one, else the deployment default
+ (`II18nService.getDefaultLocale()`, the #8195 ruled source), else
+ `sendTemplate`'s documented `en-US` ladder. Template-resolution failures
+ (`TEMPLATE_NOT_FOUND` / `TEMPLATE_INACTIVE` / `MISSING_VARIABLES`, and an
+ email service without `sendTemplate`) are graded `permanent` — dead
+ immediately with the code on the delivery row, instead of burning the retry
+ schedule on metadata that cannot fix itself.
+
+ The inbox channel keeps its existing rendering (notification title/body,
+ falling back to the topic on the template path): it has no locale-capable
+ rendering seam to the email-template subsystem today, and that gap is
+ documented in the PR rather than papered over with a duplicated resolver.
+
+### Patch Changes
+
+- 5aadce3: 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).
+- bcf2755: `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.
+- 2277443: fix(cloud-connection,service-automation): stop two plugin classes renaming themselves in the shipped build, and enforce the class-name identity limb against `Ctor.name` (#8645)
+
+ `Serve.providesCapability` (`packages/cli/src/commands/serve.ts`) decides whether a
+ host already supplied a capability's provider by comparing, by equality, both a
+ loaded plugin's `name` and its `constructor.name` against a declared identity
+ list. Every identity registry in that file therefore declares two spellings per
+ provider — the registered `plugin.name` id and the exported class name — and the
+ class-name spelling is a claim about the **built** artifact.
+
+ **Measured against the built packages, two of the 27 declared class-name
+ identities matched nothing at all:**
+
+ ```
+ MISMATCH CAPABILITY_PROVIDERS.automation declared=AutomationServicePlugin runtime=_AutomationServicePlugin
+ MISMATCH Serve.MARKETPLACE_PROXY_IDENTITIES declared=MarketplaceProxyPlugin runtime=_MarketplaceProxyPlugin
+ ```
+
+ Both classes referenced themselves **by name inside their own body** —
+ `MarketplaceProxyPlugin.prototype.version` building the outbound proxy
+ User-Agent, and a `private static` backoff helper called from an instance method
+ in the automation plugin. esbuild rewrites such a class into
+ `var X = class _X { … _X … }` so the inner reference binds to the class binding
+ rather than the outer `var`, and the emitted class reports `_X` as its `.name`.
+
+ There was no user-visible impact, because every guard naming these plugins also
+ declares the registered id, which the instance carries as a plain field no
+ bundler touches. What was dead is the **redundancy**: a guard running on one
+ limb it does not know it is running on is one rename away from failing open —
+ and failing open here means silently mounting a second instance over a host's
+ own.
+
+ Both source idioms are replaced with module-scope declarations, so the shipped
+ classes keep their names. The marketplace proxy's self-reference was also
+ reading a field that was never there (`version` is an instance field, so
+ `prototype.version` was always `undefined`): its outbound `User-Agent` announced
+ the `?? '1.0.0'` fallback on every request and now announces the plugin's real
+ version, `1.1.0`.
+
+ The enforcement half lives in `packages/cli/test/serve-capability-identity.test.ts`:
+ every declared class-name identity, across `CAPABILITY_PROVIDERS` and the four
+ marketplace identity lists, is now compared to the runtime `Ctor.name` of the
+ export it names, and must satisfy `providesCapability` through the class-name
+ limb alone. The `*_IDENTITIES` statics are re-derived from `Serve` itself, so a
+ fifth list cannot be added without being enumerated. #8357's local
+ "modulo one leading underscore" accommodation is retired rather than left as a
+ third spelling of the same rule.
+- 7ff5aa2: `sys_automation_run` resolves its organization from the DECLARED `AutomationContext.tenantId` and from no other spelling (cloud#1395).
+
+ The suspended-run store read `context.organizationId ?? context.tenantId`. `AutomationContext` declares `tenantId` and not `organizationId`, and no producer writes the latter — `RecordChangeTrigger.buildContext` maps the hook session's organization onto `tenantId`, and the runtime's automation domain sets `tenantId` directly. The dead limb was not inert: the one test covering `sys_automation_run.organization_id` fed the phantom key, so the column's only coverage exercised a path production cannot reach and said nothing about the live one. The limb is removed, the fixture speaks the declared contract, and a test now asserts the absence so restoring the alias goes red.
+
+ Both `sys_approval_request.organization_id` and `sys_automation_run.organization_id` now document the measured attribution defect this uncovered and the negative control that makes it a defect: on a walled single-database boot these two tables stored customer activity with no organization (27/27 and 31/31) while `sys_audit_log` (1669 rows) was correctly attributed on the same boot, because the audit writer resolves the organization from the record the row is ABOUT rather than from the acting context. The write-side repair is not in this change — which column a side-table row should follow is an open contract question, since the audit resolver is scope-pinned to audit stamping by the #8778 ruling. The current behaviour is pinned by test so the fix must promote the assertion rather than quietly satisfy it.
+- 0425db9: Published READMEs link to the docs site in the one form that works on npm, on GitHub and on the docs site (#9632)
+
+ **Seven docs links in these READMEs pointed nowhere.** They were spelled as a repo
+ path rooted at `/` — `[Flows](/content/docs/automation/flows.mdx)` — and a README in a
+ package's `files` array with `private` unset is rendered on the **npm package page** and
+ on **GitHub**, not only in this repository. There a root-relative href resolves against
+ `npmjs.com` and `github.com` respectively. It was not a docs-site route either:
+ `apps/docs/lib/source.ts` mounts `loader({ baseUrl: '/docs' })` over `content/docs`, so
+ the route for that first link is `/docs/automation/flows`, and `apps/docs/redirects.mjs`
+ carries no `/content` source that would rescue the written form. Every target page
+ existed and every one of them was reachable — only the links were not.
+
+ All seven now use the absolute form the repo had already established in
+ `create-objectstack`'s published READMEs: `https://docs.objectstack.ai/docs/...`, with
+ the path taken under `content/docs` and the page extension dropped, because the route
+ carries none. Each target was re-verified at the route level rather than as a file — the
+ two that named a **directory** (`/content/docs/automation/`,
+ `/content/docs/references/automation/`) resolve only because those directories carry an
+ `index.mdx`; a directory without one is a 404, not a section.
+
+ **Two more links in the same class were converted in the same pass.**
+ `service-knowledge` and `knowledge-ragflow` pointed at
+ `../../../content/docs/protocol/knowledge.mdx`. Those relative paths do resolve on both
+ GitHub and npm, so they are a milder defect than the seven — but they land the reader on
+ **raw MDX source** instead of the rendered page. They now point at the rendered page as
+ well. `service-knowledge`'s link text changed with it: it was the source filename in a
+ code span, which stops being an honest label once the destination is the page.
+
+ No API, behaviour or type surface changes — this is the published documentation these
+ packages ship.
+- f047810: fix(automation): evaluate a record-change flow's start condition on the re-entrant dispatch its own write causes — the loop-breaker goes back to being a backstop (#8689)
+
+ A `record-after-update` flow whose start condition, **as authored, is false on the
+ flow's own write-back**, was still re-dispatched for the same record. Nothing ran
+ away — the engine's last-resort re-entrancy breaker caught it every time — but the
+ breaker was the *only* thing working, and its own WARN said so: *"Its start
+ condition did not suppress the re-fire."*
+
+ **Which of the two candidate mechanisms — measured, not assumed.** The report named
+ two readings that need different repairs: the re-entrant dispatch *skips* condition
+ evaluation, or evaluation *runs but aborts* and the abort is counted as a fire.
+ Measured on a real booted kernel (ObjectQL + automation + record-change trigger on
+ better-sqlite3), a flow guarded on `record.status != "escalated"` whose data node
+ writes `status = "escalated"`:
+
+ ```
+ dispatches for the record ........ 2 (the re-fire really happened)
+ start-condition evaluations ...... 1 (the FIRST dispatch only)
+ evaluations that threw ........... 0
+ loop-breaker WARNs for that id ... 1
+ ```
+
+ Two dispatches, one evaluation, zero throws: the first reading is the true one, and
+ the second is falsified for this path. `AutomationEngine.execute()` checked the
+ re-entrancy breaker **before** the start-condition gate and returned there, so on the
+ one dispatch where an author's re-fire guard is load-bearing, the guard was never
+ consulted at all.
+
+ **The fix is the ordering, not a stronger breaker.** The gate now runs first; the
+ breaker check moved below it. The re-entrant dispatch already carries the post-write
+ row, so the condition evaluates `false` and the flow is suppressed with
+ `condition_not_met` — by the guard its author wrote. Measured after the change on the
+ same harness: 2 dispatches, **2** evaluations (the second returning `false` against
+ `status = "escalated"`), **0** breaker WARNs, and the flow still fires and applies its
+ write exactly as before.
+
+ The breaker is **unchanged in strength**, deliberately — making it catch more while
+ leaving evaluation broken would have been the wrong direction. A condition that is
+ genuinely true on re-entry (the 2026-07-06 shape: a `boolean` persists as integer `1`
+ on SQLite/libsql, and CEL `1 != true` is true, so `is_escalated != true` never trips)
+ still lands on the breaker, at the same depth, with the same WARN and the same skip
+ envelope. What changed is that reaching it now *means* something — the condition was
+ evaluated and returned true — so the WARN states that as fact instead of inferring it.
+
+ Two consequences worth naming for anyone reading logs or run history:
+
+ - flows whose re-fire guard was already correct stop producing the breaker WARN
+ entirely, and their re-entrant dispatch is now recorded as `condition_not_met`
+ rather than `reentrancy_loop_guard`;
+ - a run skipped by its condition, and a re-entrant dispatch refused by the breaker,
+ no longer release the re-entrancy key — only the run that took it does. Releasing a
+ key it never owned would have disarmed the breaker for the run still on the stack,
+ which is exactly the runaway the breaker exists to stop.
+
+ The regression pins assert the reporter's own three-legged probe design together —
+ the flow actually fired, no breaker WARN carries that record's id, and the start
+ condition was **evaluated** at the re-fire against the post-write row and returned a
+ verdict rather than throwing. Asserting only "the flow terminated" would be vacuous
+ here: the breaker already made that true.
+- c86799f: fix(service-automation): a retry attempt that PAUSES is a durable pause, not a failed attempt — `executeWithoutRetry` gets the ADR-0019 suspend arm (#9510)
+
+ `execute()`'s catch tests the suspend signal FIRST, and that arm is what makes
+ ADR-0019's durable pause work: it snapshots the live variables, calls
+ `persistSuspendedRun`, records a `paused` log entry and returns
+ `{ success: true, status: 'paused', runId }`.
+
+ `executeWithoutRetry()` — the method `retryExecution` re-runs the flow through on
+ **every** retry attempt — had no such arm. A `FlowSuspendSignal` thrown on a
+ retry attempt fell into the generic failure path, and four things were lost at
+ once:
+
+ 1. `persistSuspendedRun` never ran, so **the continuation was never stored** and
+ the run could not be resumed by anyone, ever;
+ 2. the run log recorded `failed` for a run that asked to pause;
+ 3. the caller got `status: 'failed'`, with the suspend signal stringified into
+ `error` (`FlowSuspendSignal` is not an `Error`);
+ 4. `retryExecution` reads only `result.success`, so the pause counted as one more
+ failed attempt: the loop burned the rest of the budget, and every further
+ attempt re-entered the pausing node and orphaned another suspension.
+
+ Only a LATER attempt is exposed — `execute()` handles the first one correctly,
+ and a flow reaches `retryExecution` only after a failure. The reachable shape is
+ the ordinary one: `errorHandling.strategy: 'retry'` on a flow whose flaky
+ HTTP/connector call is followed by an `approval` or `screen` node.
+
+ **⚠️ Runs already lost to this defect are NOT recoverable.** Nothing was written
+ for them — no `sys_automation_run` row, no in-memory suspension — so there is no
+ continuation to rehydrate and no repair, here or later, can bring one back. The
+ run log holds a `failed` entry naming the flow and the trigger; those runs have
+ to be triggered again. What this change fixes is every run from here on.
+
+ **The repair is a restoration of a stated contract on a path that never got it,
+ not a new capability.** `AutomationResult.status: 'paused'` and ADR-0019 already
+ describe exactly this behaviour, and `execute()`'s own arm already implements it;
+ the retry path simply never received it. The alternative — refusing
+ `strategy: 'retry'` combined with a pausing node at authoring time — was
+ considered and rejected: it over-refuses (a pausing node can sit on a branch the
+ retrying path never reaches), under-refuses (a pausing node behind a runtime
+ condition is not statically decidable), and would ban the one combination authors
+ most reasonably reach for.
+
+ **The cost, and what was done about it.** Lifting the arm makes `retryExecution`
+ able to return a NON-TERMINAL result, and both of its readers were taught the
+ third state explicitly rather than left to a branch that happens to fall through:
+ the retry loop returns a paused attempt because it PAUSED (tested on `status`,
+ before the `success` check that means "this attempt succeeded"), and the trigger
+ route answers it from its own arm. The retry accounting is untouched — a
+ genuinely failing attempt still consumes one, `maxRetries` still bounds the loop,
+ and the loop stops only because the attempt did not fail.
+
+ **Both routes give one answer**, pinned as an equality rather than verified in
+ isolation: a pause on attempt 1 and a pause on attempt 3 produce the same engine
+ result and the same wire response, so no caller can tell which attempt paused.
+
+ Two adjacent gaps were measured out of this work and filed rather than absorbed:
+ a retry attempt runs with a smaller variable environment than the first (#9704),
+ and a flow's declared retry policy stops applying once a run pauses (#9705) —
+ the latter being the measured answer to "what happens to the retry budget when a
+ paused run is resumed and then fails": neither inherited nor fresh, because the
+ resume path has no retry loop at all. Both are pinned as today's behaviour so
+ neither can change by accident.
+- b030055: fix(service-automation): a retry attempt now runs with the same variable environment as the first
+
+ `executeWithoutRetry()` — the method the retry loop re-runs a flow through on every
+ attempt — seeded only the flow's declared variables and `$record`, while the first
+ attempt also binds `record` plus the triggering record's flattened fields, `previous`,
+ `$runId`, `$flowName` and `$flowLabel`. Every retry attempt therefore ran in a strictly
+ smaller environment than attempt 1.
+
+ Because conditions are strict CEL, where reading an unbound name aborts the predicate
+ rather than yielding `false`, this was user-visible exactly where retry is most used —
+ `errorHandling.strategy: 'retry'` on a record-change flow:
+
+ - a start condition or edge predicate reading `previous` (the create-vs-update
+ discriminator) aborted on the retry, so the retry failed for a reason the first attempt
+ never hit — reading as a flaky flow rather than a defect;
+ - a bare reference to a triggering-record field (`status`, `budget`) aborted for the same
+ reason;
+ - a pausing node (e.g. Approval) reached on a retry attempt saw no `$runId`, so the
+ external state it minted could not be mapped back to the run for resume (ADR-0019).
+
+ Both methods now seed through one shared chokepoint. First-attempt behaviour is unchanged.
+- f01c0ee: docs: five published service READMEs stop documenting an API that does not exist (#9532)
+
+ A version bump is the point, not a side effect: these five READMEs are in their
+ packages' `files` arrays with `private` unset, so they are the pages npm renders —
+ and a docs-only fix with no bump never reaches npm at all.
+
+ Each of the five told a reader to an import of a `Service…` class from its own package
+ and call a static `.configure({...})` on it. Neither has ever existed: no class in
+ this repo exposes a static `configure`, and none of `ServiceAnalytics`,
+ `ServiceAutomation`, `ServiceCache`, `ServiceI18n` or `ServiceJob` is exported by
+ anything. A reader following any of them wrote code that could not compile. The real
+ entry point in every case is a kernel plugin constructed with `new`:
+ `AnalyticsServicePlugin`, `AutomationServicePlugin`, `CacheServicePlugin`,
+ `I18nServicePlugin`, `JobServicePlugin`.
+
+ ⛔ A name swap alone would not have been enough, and the gate landed in #9546 is what
+ proves it: substituting the genuine class while keeping `.configure(...)` turns the
+ import finding into a call-site finding rather than into silence. Each README is
+ rewritten against the package's built type surface, and each package's entry is
+ deleted from `scripts/published-readme-exports.baseline.json` in the same change
+ (the baseline is reconciled in both directions, so a stale entry fails too).
+
+ What was removed as fabricated, beyond the entry point:
+
+ - **service-analytics** — a nine-endpoint REST surface (`/analytics/count`, `/sum`,
+ `/avg`, `/min`, `/max`, `/group-by`, `/time-series`, `/metrics`, `/metrics/:name`)
+ of which none exists; the real surface is `POST /analytics/query`,
+ `GET /analytics/meta`, `POST /analytics/sql` and `POST /analytics/dataset/query`.
+ Also removed: `defineMetric`, `getMetric`, `compare`, `funnel`,
+ `executeDashboard`, `invalidateCache`, and an `AnalyticsServiceConfig` block whose
+ four keys (`defaultDriver`, `enableCaching`, `cacheTTL`, `maxMemoryResults`) are
+ none of the real ones.
+ - **service-automation** — `executeFlow`/`getFlow`/`listFlows`/`getFlowHistory`/
+ `registerTrigger` as the contract (the real contract is `execute(flowName, context?)`
+ plus `listFlows()` and a set of optional members), and a five-endpoint REST list that
+ matches no mounted route. The flow-authoring half of that README was already accurate
+ and is kept.
+ - **service-cache** — `mget`/`mset`/`del`/`delPattern`/`namespace`/`ttl`/`expire`/
+ `persist`/`incr`/`incrby`/`decr`/`getOrSet`/`invalidateTag`/`resetStats`, none of
+ which exist; `ICacheService` has six members. `CacheStats.keys`/`hitRate` corrected to
+ `keyCount` (there is no `hitRate`), and `set(key, value, { ttl })` corrected to the
+ real positional `set(key, value, ttl?)` in seconds.
+ - **service-i18n** — an `await i18n.t('ns:key')` dialect with namespaces, plural
+ suffixes, `context`, `returnObjects`, `setLocale`/`getLocale`, `formatDate`/
+ `formatNumber`/`formatRelative`, `addLocale`/`removeLocale`/`reload`, `getCoverage`/
+ `getMissingKeys`, and a `{{lng}}/{{ns}}` file layout. The real `t()` is synchronous
+ and takes the locale positionally — `t(key, locale, params?)` — over one
+ `{locale}.json` file per locale. The `POST /i18n/translate` endpoint does not exist.
+ - **service-job** — `scheduleInterval`/`scheduleOnce`/`getJob`/`stopJob`/`resumeJob`/
+ `deleteJob`/`runNow`/`getJobHistory`/`clearHistory`/`getLastExecution`, and a
+ `schedule({ name, schedule, handler })` options-object call. The real `schedule` is
+ positional — `schedule(name, schedule, handler, options?)` — and returns `void`.
+ Retry defaults corrected to the enforced ones (`maxRetries: 0`,
+ `backoffMultiplier: 1`).
+
+ Two capability claims are corrected rather than deleted, because the source is what
+ decides:
+
+ - **service-cache** advertised Redis as production support. `RedisCacheAdapter` throws
+ `RedisCacheAdapter not yet implemented` from every method, and
+ `new CacheServicePlugin({ adapter: 'redis' })` throws during `init` rather than
+ falling back to memory. The README now says so at the top and points at registering
+ a custom `ICacheService` under the slot instead.
+ - **service-job**'s `adapter: 'interval'` stores cron registrations that never fire.
+ That is now stated in the adapter table rather than left for a reader to discover.
+
+ No compliance claim (SOC 2 / HIPAA / GDPR or similar) was found in any of the five —
+ the shape that raised `plugin-audit`'s severity in #9517 is absent here.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/formula@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json
index b9194556c7..bae3224def 100644
--- a/packages/services/service-automation/package.json
+++ b/packages/services/service-automation/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-automation",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Automation Service for ObjectStack — implements IAutomationService with plugin-based DAG flow execution engine",
"type": "module",
diff --git a/packages/services/service-cache/CHANGELOG.md b/packages/services/service-cache/CHANGELOG.md
index 618fb94689..167fdd4f78 100644
--- a/packages/services/service-cache/CHANGELOG.md
+++ b/packages/services/service-cache/CHANGELOG.md
@@ -1,5 +1,242 @@
# @objectstack/service-cache
+## 17.1.0
+
+### Patch Changes
+
+- 0425db9: Published READMEs link to the docs site in the one form that works on npm, on GitHub and on the docs site (#9632)
+
+ **Seven docs links in these READMEs pointed nowhere.** They were spelled as a repo
+ path rooted at `/` — `[Flows](/content/docs/automation/flows.mdx)` — and a README in a
+ package's `files` array with `private` unset is rendered on the **npm package page** and
+ on **GitHub**, not only in this repository. There a root-relative href resolves against
+ `npmjs.com` and `github.com` respectively. It was not a docs-site route either:
+ `apps/docs/lib/source.ts` mounts `loader({ baseUrl: '/docs' })` over `content/docs`, so
+ the route for that first link is `/docs/automation/flows`, and `apps/docs/redirects.mjs`
+ carries no `/content` source that would rescue the written form. Every target page
+ existed and every one of them was reachable — only the links were not.
+
+ All seven now use the absolute form the repo had already established in
+ `create-objectstack`'s published READMEs: `https://docs.objectstack.ai/docs/...`, with
+ the path taken under `content/docs` and the page extension dropped, because the route
+ carries none. Each target was re-verified at the route level rather than as a file — the
+ two that named a **directory** (`/content/docs/automation/`,
+ `/content/docs/references/automation/`) resolve only because those directories carry an
+ `index.mdx`; a directory without one is a 404, not a section.
+
+ **Two more links in the same class were converted in the same pass.**
+ `service-knowledge` and `knowledge-ragflow` pointed at
+ `../../../content/docs/protocol/knowledge.mdx`. Those relative paths do resolve on both
+ GitHub and npm, so they are a milder defect than the seven — but they land the reader on
+ **raw MDX source** instead of the rendered page. They now point at the rendered page as
+ well. `service-knowledge`'s link text changed with it: it was the source filename in a
+ code span, which stops being an honest label once the destination is the page.
+
+ No API, behaviour or type surface changes — this is the published documentation these
+ packages ship.
+- c07d6e8: fix(services): two published `.d.ts` JSDoc comments stop describing behaviour their code does not have — `MemoryCacheAdapter` eviction is FIFO, not LRU, and `recordRuns` is an on/off switch, not a retention cap (#9611)
+
+ Both comments are emitted by `tsup` into each package's built `index.d.ts`, so they
+ are **the editor tooltip an npm consumer sees** — the same "published documentation
+ asserting behaviour the runtime does not have" class as #9517 and #9532, in a third
+ channel that no gate reads. No runtime behaviour changes in either package; this is a
+ patch because the corrected text only reaches consumers through a release.
+
+ **1. `MemoryCacheAdapter` — "LRU-style eviction" was never LRU.**
+
+ The class comment advertised `TTL-based expiry and LRU-style eviction`. The eviction
+ path takes `this.store.keys().next().value` — the first key in `Map` insertion order —
+ and `get()` returns `entry.value` without ever deleting and re-setting the key, so a
+ read does not move an entry back. Nor does an overwrite: `Map.set` on a key already
+ present keeps its original insertion slot. Eviction has therefore always been
+ **oldest-inserted (FIFO)**, which is a materially different hit-rate profile from the
+ one the tooltip promised anyone sizing a cache.
+
+ The comment was corrected rather than the code, deliberately: `maxSize` defaults to `0`
+ (unlimited), so the eviction path is off by default and nothing shipped is getting FIFO
+ where it expected LRU, and there is no measured pull for LRU. Minting a real behaviour
+ change to make a stale sentence true inverts the fix — the defect is that the
+ documentation lies, not that the cache is wrong. (A real LRU already exists in the repo,
+ `packages/metadata/src/utils/lru-cache.ts`, for the callers that need one.)
+
+ Four tests now pin the corrected sentence so it stops being an unenforced claim. Each is
+ written as a **discriminator against LRU**: it reads (or overwrites) the oldest entry
+ before overflowing the cache and then asserts that entry was evicted anyway — a hot key
+ dies on age, an untouched newer key survives. The pre-existing eviction tests could not
+ tell the two policies apart, which is how the wrong comment sat green.
+
+ **2. `DbJobAdapterOptions.recordRuns` — the comment described a different field.**
+
+ `/** Soft cap on sys_job_run rows recorded per job (defaults to none — handled by
+ retention jobs) */` made three claims and the code contradicts all three: the field is a
+ `boolean`, not a count; it defaults to `true`, not "none" (`args.options?.recordRuns ??
+ true`); and it gates whether a `sys_job_run` row is written at all, rather than being
+ trimmed later by retention. The sentence reads as if it belongs to the numeric
+ `JobRunRetention` knob that ADR-0057 retired — a copy-paste that outlived its source.
+
+ The consequence the new wording keeps in sight: **a reader who sets `recordRuns: false`
+ expecting "no cap" gets run history switched off.** The replacement states the real
+ meaning (one row per attempt, inserted at start and updated on settle, default `true`)
+ and both things that are *not* affected by the flag — the `sys_job` row's own
+ `last_status` / `run_count` / `failure_count` counters, which `bumpJob` updates
+ regardless, and `replay()`, which writes its synthetic `trigger: 'replay'` row without
+ consulting the flag at all.
+- f01c0ee: docs: five published service READMEs stop documenting an API that does not exist (#9532)
+
+ A version bump is the point, not a side effect: these five READMEs are in their
+ packages' `files` arrays with `private` unset, so they are the pages npm renders —
+ and a docs-only fix with no bump never reaches npm at all.
+
+ Each of the five told a reader to an import of a `Service…` class from its own package
+ and call a static `.configure({...})` on it. Neither has ever existed: no class in
+ this repo exposes a static `configure`, and none of `ServiceAnalytics`,
+ `ServiceAutomation`, `ServiceCache`, `ServiceI18n` or `ServiceJob` is exported by
+ anything. A reader following any of them wrote code that could not compile. The real
+ entry point in every case is a kernel plugin constructed with `new`:
+ `AnalyticsServicePlugin`, `AutomationServicePlugin`, `CacheServicePlugin`,
+ `I18nServicePlugin`, `JobServicePlugin`.
+
+ ⛔ A name swap alone would not have been enough, and the gate landed in #9546 is what
+ proves it: substituting the genuine class while keeping `.configure(...)` turns the
+ import finding into a call-site finding rather than into silence. Each README is
+ rewritten against the package's built type surface, and each package's entry is
+ deleted from `scripts/published-readme-exports.baseline.json` in the same change
+ (the baseline is reconciled in both directions, so a stale entry fails too).
+
+ What was removed as fabricated, beyond the entry point:
+
+ - **service-analytics** — a nine-endpoint REST surface (`/analytics/count`, `/sum`,
+ `/avg`, `/min`, `/max`, `/group-by`, `/time-series`, `/metrics`, `/metrics/:name`)
+ of which none exists; the real surface is `POST /analytics/query`,
+ `GET /analytics/meta`, `POST /analytics/sql` and `POST /analytics/dataset/query`.
+ Also removed: `defineMetric`, `getMetric`, `compare`, `funnel`,
+ `executeDashboard`, `invalidateCache`, and an `AnalyticsServiceConfig` block whose
+ four keys (`defaultDriver`, `enableCaching`, `cacheTTL`, `maxMemoryResults`) are
+ none of the real ones.
+ - **service-automation** — `executeFlow`/`getFlow`/`listFlows`/`getFlowHistory`/
+ `registerTrigger` as the contract (the real contract is `execute(flowName, context?)`
+ plus `listFlows()` and a set of optional members), and a five-endpoint REST list that
+ matches no mounted route. The flow-authoring half of that README was already accurate
+ and is kept.
+ - **service-cache** — `mget`/`mset`/`del`/`delPattern`/`namespace`/`ttl`/`expire`/
+ `persist`/`incr`/`incrby`/`decr`/`getOrSet`/`invalidateTag`/`resetStats`, none of
+ which exist; `ICacheService` has six members. `CacheStats.keys`/`hitRate` corrected to
+ `keyCount` (there is no `hitRate`), and `set(key, value, { ttl })` corrected to the
+ real positional `set(key, value, ttl?)` in seconds.
+ - **service-i18n** — an `await i18n.t('ns:key')` dialect with namespaces, plural
+ suffixes, `context`, `returnObjects`, `setLocale`/`getLocale`, `formatDate`/
+ `formatNumber`/`formatRelative`, `addLocale`/`removeLocale`/`reload`, `getCoverage`/
+ `getMissingKeys`, and a `{{lng}}/{{ns}}` file layout. The real `t()` is synchronous
+ and takes the locale positionally — `t(key, locale, params?)` — over one
+ `{locale}.json` file per locale. The `POST /i18n/translate` endpoint does not exist.
+ - **service-job** — `scheduleInterval`/`scheduleOnce`/`getJob`/`stopJob`/`resumeJob`/
+ `deleteJob`/`runNow`/`getJobHistory`/`clearHistory`/`getLastExecution`, and a
+ `schedule({ name, schedule, handler })` options-object call. The real `schedule` is
+ positional — `schedule(name, schedule, handler, options?)` — and returns `void`.
+ Retry defaults corrected to the enforced ones (`maxRetries: 0`,
+ `backoffMultiplier: 1`).
+
+ Two capability claims are corrected rather than deleted, because the source is what
+ decides:
+
+ - **service-cache** advertised Redis as production support. `RedisCacheAdapter` throws
+ `RedisCacheAdapter not yet implemented` from every method, and
+ `new CacheServicePlugin({ adapter: 'redis' })` throws during `init` rather than
+ falling back to memory. The README now says so at the top and points at registering
+ a custom `ICacheService` under the slot instead.
+ - **service-job**'s `adapter: 'interval'` stores cron registrations that never fire.
+ That is now stated in the adapter table rather than left for a reader to discover.
+
+ No compliance claim (SOC 2 / HIPAA / GDPR or similar) was found in any of the five —
+ the shape that raised `plugin-audit`'s severity in #9517 is absent here.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/observability@17.1.0
+
## 17.0.0
### Patch Changes
diff --git a/packages/services/service-cache/package.json b/packages/services/service-cache/package.json
index 4b991f016b..2ec3979965 100644
--- a/packages/services/service-cache/package.json
+++ b/packages/services/service-cache/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-cache",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Cache Service for ObjectStack — implements ICacheService with in-memory and Redis adapters",
"type": "module",
diff --git a/packages/services/service-cluster-redis/CHANGELOG.md b/packages/services/service-cluster-redis/CHANGELOG.md
index 04b5bb0ba8..7608c1a985 100644
--- a/packages/services/service-cluster-redis/CHANGELOG.md
+++ b/packages/services/service-cluster-redis/CHANGELOG.md
@@ -1,5 +1,89 @@
# @objectstack/service-cluster-redis
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [19539b4]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/service-cluster@17.1.0
+
## 17.0.0
### Patch Changes
diff --git a/packages/services/service-cluster-redis/package.json b/packages/services/service-cluster-redis/package.json
index c1b253aad5..782dd3efd1 100644
--- a/packages/services/service-cluster-redis/package.json
+++ b/packages/services/service-cluster-redis/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-cluster-redis",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Redis cluster driver for ObjectStack — implements IPubSub/ILock/IKV/ICounter against Redis using ioredis.",
"type": "module",
diff --git a/packages/services/service-cluster/CHANGELOG.md b/packages/services/service-cluster/CHANGELOG.md
index 99446ea46e..0525eefa2c 100644
--- a/packages/services/service-cluster/CHANGELOG.md
+++ b/packages/services/service-cluster/CHANGELOG.md
@@ -1,5 +1,96 @@
# @objectstack/service-cluster
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json
index e8b09176d0..d50e2b1776 100644
--- a/packages/services/service-cluster/package.json
+++ b/packages/services/service-cluster/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-cluster",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Cluster Service for ObjectStack — pluggable PubSub/Lock/KV/Counter primitives. Memory driver included; postgres/redis drivers ship separately.",
"type": "module",
diff --git a/packages/services/service-datasource/CHANGELOG.md b/packages/services/service-datasource/CHANGELOG.md
index ccf2e4b16b..acf6430e11 100644
--- a/packages/services/service-datasource/CHANGELOG.md
+++ b/packages/services/service-datasource/CHANGELOG.md
@@ -1,5 +1,621 @@
# @objectstack/service-external-datasource
+## 17.1.0
+
+### Minor Changes
+
+- 3508678: feat(service-datasource): operator-initiated re-homing of stored cleartext datasource credentials into `sys_secret` (#8155)
+
+ A datasource row created before #8078 closed the write door can still hold its
+ credential in cleartext inside `config`. #8081 and #8154 closed the read paths so
+ none of it is SERVED; neither removes what is already at rest. This adds the
+ migration that does — `IDatasourceAdminService.migrateCredential(name)`, reached
+ from the Setup action **"Move credential to the secret store"** on a datasource
+ record, backed by `POST /api/v1/datasources/:name/migrate-credential`.
+
+ **Per datasource, initiated by an operator, never a sweep.** There is no batch
+ spelling of the route, deliberately: deciding a stored secret's identity with no
+ operator present and rewriting rows at boot is the destructive shape the standing
+ ruling escalates rather than permits. The inventory is free and already exists —
+ `/meta` badges every affected row `_diagnostics: { valid: false }`, so the
+ operator works from a list the platform already computes, and it shrinks visibly
+ as each row is done.
+
+ **Durability ordering.** The secret is written to the store, **read back and
+ compared**, and only then does a single record write add
+ `external.credentialsRef` and drop the inline key together. A crash before that
+ write leaves the row untouched and working on its inline credential; a crash
+ after it leaves a row referencing a secret this run already proved readable. A
+ failed read-back or a failed record write unbinds the secret it just minted
+ rather than orphaning it. It deliberately does NOT write the ref in one step and
+ delete the key in a second: the connect path is fail-closed on a `credentialsRef`
+ it cannot resolve (ADR-0062 D3) and never falls back to `config`, so a row
+ carrying an unverified ref beside its cleartext is not a safe intermediate state.
+
+ **Idempotent.** A row that already references a secret is never bound again — a
+ re-run answers `already-bound`, writes nothing, and mints no second `sys_secret`
+ row. A row holding both a ref and an inline copy (an interrupted run, or a wizard
+ re-entry, whose redacted round-trip carries the stored credential forward by
+ design) has the copy dropped against the ref it already has.
+
+ **What it refuses, and what it tells the operator instead.** Only the key a
+ driver's own contract declares as its inline credential slot is re-homed —
+ `password` for postgres/mysql/mongodb, `authToken` for turso — because that is
+ exactly the key the injected secret substitutes at connect time. Everything else
+ is refused with a reason and a remedy rather than guessed at: a credential
+ embedded in a connection URL (the mysql and mongodb DSN branches hand the URL to
+ the client verbatim and drop the injected secret, so re-homing it could leave the
+ datasource connecting unauthenticated), a pre-#8078 alias spelling that no
+ connection builder reads, turso's still-writable `encryptionKey`, a code-defined
+ datasource, and a host whose secret binder cannot read a secret back. Nothing is
+ deleted that was not re-homed, and credential-shaped keys left behind are named
+ in the result so "migrated" never reads as "this row is now clean".
+- 20067c5: fix(runtime,mcp,service-datasource): the #6504 consumer sweep — three list consumers stop making claims a known-partial read cannot support (#6504)
+
+
+
+ `IMetadataService.listDiagnosed?(type)` (PR #7721) lets a plural read say whether
+ its answer can be trusted as complete. This is the consumer half: the callers
+ that were restating a possibly-short listing as a fact about the environment.
+
+ Each consumer was qualified individually, per PR #6051's discipline, and most
+ were left alone — a caller publishing a snapshot with no count has nothing to
+ mis-state. Three make a claim, and each now withholds exactly that claim while
+ still serving everything it could read:
+
+ - **`removeDatasource` no longer deletes on a bound-object count it could not
+ take completely.** The guard `if (bound > 0) throw` is the only thing standing
+ in front of an irreversible delete that also unbinds the datasource's secret,
+ and its input is derived from the metadata service's object listing. During a
+ loader outage that listing goes silently short, and the worst value is the
+ benign one: `0` reads exactly like "nothing is bound", so the guard OPENED.
+ It now refuses with `SERVICE_UNAVAILABLE` / 503 — a dependency outage the
+ operator can retry, not a client error — and the record, its credential and
+ its pool all survive.
+ - **The MCP `list_objects` tool stops publishing `totalCount` on a known-partial
+ listing.** This is the same claim PR #7721 removed from the
+ `objectstack://objects` resource, on the other MCP primitive: same payload
+ shape, different door, never covered. A degraded read now serves the same
+ objects with `totalCount` **absent** and `partial` / `returnedCount` /
+ `warning` plus the 503 envelope in its place, so a client reading the total
+ gets `undefined` rather than a believable wrong integer. Both bridges
+ implement it — stdio (`@objectstack/mcp`) and HTTP (`@objectstack/runtime`) —
+ because a completeness claim must not depend on which transport a client
+ connected over.
+ - **The ADR-0015 §5.2 boot gate stops announcing an all-clear over a sweep it
+ could not complete.** It validated whatever `listObjects()` returned and then
+ logged *all federated objects match their remote schema*, with a count.
+ Federated objects behind an unreadable loader were never validated, so
+ `onMismatch: 'fail'` could not have fired for them. The gate now warns that
+ the swept set was incomplete and names what it did validate. ⛔ It does **not**
+ abort boot on a degraded metadata read: turning a transient outage into a
+ refusal to start would be a new failure mode bought with a diagnosis fix.
+
+ Every new member is optional in the same way `listDiagnosed` itself is: a host
+ whose metadata service predates the verdict behaves exactly as it did before,
+ and a service without it reports nothing degraded — precisely what it could
+ express.
+
+### Patch Changes
+
+- 5c38492: 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.
+- 2420641: feat(spec): refuse a credential in the mongo options passthrough (`config.options.auth.password`) at publish (#9040)
+
+ **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
+ launch-window convention ships it as `minor`; the migration prescription is
+ registered under protocol major 18, where `os migrate meta` users will look).
+
+ The FOURTH spelling of the same inline secret: #7990 refused the top-level
+ `password` key, #8082 the URL userinfo (`user:password@host`), #8337 the
+ credential-bearing URL query parameters — and the MongoClient `options`
+ passthrough stayed open one syntax over.
+ `options: { auth: { username, password } }` parsed green, persisted the
+ password cleartext into `sys_metadata` (served back by the ordinary data API,
+ unredacted), and genuinely authenticated: measured on `mongodb@7.5.0`, the
+ client the driver spreads `config.options` into, the block is transformed into
+ `MongoCredentials` — so the workaround was live, not inert.
+
+ **What is refused** (write door, closed measured list
+ `MONGO_OPTIONS_CREDENTIAL_PATHS` behind `credentialFreeMongoOptions`, composed
+ with the #8336 placeholder refusal on the same slot): a NON-EMPTY STRING
+ `options.auth.password`, with the binder prescription — and the "wins over"
+ reassurance is true for this syntax: a bound `external.credentialsRef` secret
+ outranks the passthrough `auth` block at connect (#8696, measured).
+ Deliberately not refused, each measured: `auth.username` alone (#8876's
+ asymmetry — a username is not credential material), an empty password (the
+ passthrough twin of `user:@host`), every legitimate passthrough option
+ (`replicaSet`, `tls`, timeouts — byte-identical pins),
+ `authMechanismProperties.AWS_SESSION_TOKEN` (the v7 client itself throws on it
+ under MONGODB-AWS and nothing reads it otherwise), and the binder-slotless
+ client secrets (`proxyPassword`, `tlsCertificateKeyFilePassword`, `key`,
+ `passphrase`) — refusing those would name a remedy that does not exist (the
+ binder fills exactly one slot; the turso-`encryptionKey` posture, #8081
+ item 4).
+
+ **Read half** (additive, never the substitute — #8082's ruling): stored
+ passthrough secrets are now redacted on every read exit —
+ `options.auth.password` plus the binder-slotless names above and
+ `AWS_SESSION_TOKEN` — reported as dotted `redactedKeys`
+ (`options.auth.password`), which the metadata write door's generic
+ carry-forward already walks, so an untouched "Save" keeps the stored
+ credential on both admin doors (`restoreRedactedConfig` mirrors per leaf).
+ The #8155 credential-migration planner refuses a stored passthrough-credential
+ row with the per-row remedy instead of planning `nothing-to-migrate` over live
+ cleartext (dropping only the nested leaf would leave an `auth` block the
+ client refuses at construction, measured).
+
+ ## FROM → TO
+
+ ```yaml
+ # before — parsed green; password stored cleartext in sys_metadata and
+ # resolved into MongoCredentials at connect
+ driver: mongodb
+ config:
+ url: mongodb://app@mongo.internal:27017/events
+ options:
+ replicaSet: rs0
+ auth: { username: app, password: PLAINTEXT-IN-METADATA }
+
+ # after — rejected with the binder prescription; bind the secret instead
+ driver: mongodb
+ config:
+ url: mongodb://app@mongo.internal:27017/events
+ options:
+ replicaSet: rs0
+ external:
+ credentialsRef: sys_secret:01J9ZK4T2N # or the connection form's secret field
+ ```
+
+ There is deliberately no automatic rewrite: moving the value requires
+ encrypting it into `sys_secret` through a running secret binder, which a
+ source-file transform cannot do — and auto-dropping only the nested password
+ would leave an `auth` block the MongoDB client refuses outright.
+
+
+- f57fb38: feat(spec): refuse credential-bearing URL query parameters (`?authToken=` / `?password=`) in authored driver config at publish (#8337)
+
+ **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
+ launch-window convention ships it as `minor`; the migration prescription is
+ registered under protocol major 18, where `os migrate meta` users will look).
+
+ The third spelling of the same secret: #7990 refused the inline credential
+ keys, #8082 refused the URL userinfo form (`user:password@host`), and the
+ query string stayed open — `libsql://x.turso.io?authToken=eyJ…` persisted the
+ JWT cleartext into `sys_metadata` (served back by the ordinary data API) and,
+ measured against the clients this tree pins, actually authenticates:
+ `@libsql/core@0.17.4` assigns the URL's `?authToken=` OVER the config-level
+ token — so the workaround also silently defeated the binder-injected secret —
+ and `pg-connection-string@2.14.0` copies every query parameter into the client
+ config, `?password=` winning over userinfo.
+
+ **What is refused** (write door, shared value-level parse
+ `urlCredentialQueryParams` beside #8082's `urlUserinfoPassword`): turso
+ `config.url` / `config.syncUrl` carrying `?authToken=`, postgres `config.url`
+ carrying `?password=` — matched case-insensitively on the percent-decoded key,
+ non-empty values only, with the #8082-template prescription (datasource secret
+ binder / `external.credentialsRef`; runtime-environment DSNs are unaffected).
+ mysql and mongo URLs are deliberately NOT narrowed: both clients were measured
+ ignoring `?password=`, so refusing it would widen past the measured defect.
+
+ **What stays accepted:** every credential-free URL byte-identically, benign
+ query parameters (`?tls=`, `?sslmode=`, …) included, and the parameter-absent
+ shape the read path serves — which keeps an untouched "Save" on a legacy row
+ working.
+
+ **Read half** (the same PR, per the card): `redactDatasourceConfig` /
+ `getDatasource()` now strip credential query parameters from served URLs for
+ every driver (new `redactUrlCredentials` / `redactUrlCredentialQueryParams`
+ exports), `restoreRedactedConfig` mirrors the composite so an untouched
+ round-trip keeps the stored token, and the credential-migration planner
+ refuses a query-token row with the per-row remedy instead of planning
+ `nothing-to-migrate` over cleartext.
+
+ ## FROM → TO
+
+ ```yaml
+ # before — parsed green; JWT stored cleartext in sys_metadata, and at connect
+ # it silently overrode the binder-injected secret
+ driver: turso
+ config:
+ url: libsql://app-org.turso.io?authToken=eyJhbGciOiJFZERTQSJ9.x.y
+
+ # after — rejected with the binder prescription; bind the secret instead
+ driver: turso
+ config:
+ url: libsql://app-org.turso.io
+ external:
+ credentialsRef: sys_secret:01J9ZK4T2N # or the connection form's secret field
+ ```
+
+ There is deliberately no automatic rewrite: moving the value requires
+ encrypting it into `sys_secret` through a running secret binder, which a
+ source-file transform cannot do — stripping the parameter alone would silently
+ drop a live credential.
+
+
+- 90a12fb: fix(security): a mongo datasource that binds `external.credentialsRef` and authors a connection URL now connects with the bound credential instead of none (#8696)
+
+
+
+ `buildMongoUrl`'s DSN branch returned the authored `config.url` verbatim and
+ applied `spec.secret` nowhere. A mongo datasource that bound its secret through
+ `external.credentialsRef` (or the connection form's secret field) therefore
+ connected with **whatever the URL itself carried** — which, since #8082 refuses
+ a `user:password@` userinfo at the publish door, is **no credential at all**.
+ Measured on `origin/main` @ `792524c22`, mongodb 7.5.0:
+
+ ```text
+ config.url 'mongodb://app@db.internal:27017/app' + a bound secret
+ -> MongoClient credentials {username:'app', password:''}
+ ```
+
+ The connect path is fail-closed on a ref it cannot resolve, so an operator
+ reasonably reads "the datasource connected" as "the bound credential was used".
+ It was not: the credential was declared, resolved, injected into the factory —
+ and then dropped at the last call site with no diagnostic. That is
+ declared-≠-enforced (Prime Directive #10) one layer below the spec, and
+ `MongoConfigSchema.url` is the contract it broke, verbatim: *"bind the secret
+ (`external.credentialsRef` / the connection form's secret field) and **it is
+ injected at connect time**. A bare username (`user@host1`) stays writable."*
+ The arm's behaviour was decided by whether the operator happened to author a
+ URL — the composed branch five lines below had honoured the secret since #4410.
+ This closes the last arm of the family #7314 / #7385 / #8152 / #8875 have each
+ closed one driver at a time.
+
+ **The fix injects `options.auth` beside an unmodified url — it does not rewrite
+ the URL.** Measured on mongodb 7.5.0 (the `MongoClient` constructor resolves
+ credentials eagerly, so all of it is assertable with no server):
+
+ ```text
+ 'mongodb://app@db.internal:27017/app' + auth{app,BOUND} -> password BOUND
+ 'mongodb://app:embedded-legacy@h/app' + auth{app,BOUND} -> password BOUND
+ 'mongodb://app@h1:27017,h2:27017/app' + auth{app,BOUND} -> password BOUND
+ 'mongodb+srv://app@c0.example.net/app' + auth{app,BOUND} -> password BOUND
+ 'mongodb://app@h/app?authSource=admin' + auth{app,BOUND} -> source admin
+ ```
+
+ So the authored URL is handed over byte for byte, no second dialect of
+ `mongodb://…` enters this repo, the multi-host and `+srv` forms ride through
+ unharmed, and a bound secret **wins** over a legacy password embedded in a
+ stored pre-#8082 row — the same precedence the mysql arm states, reached by a
+ different mechanism because the clients merge in opposite directions. The
+ userinfo **username** `auth` also requires is read through the platform's own
+ DSN grammar (`urlUserinfoUsername`, #8876) and percent-decoded at the call
+ site: `new URL()` cannot even parse the multi-host form this schema documents,
+ and a second hand-rolled copy of those boundaries is the shape #8082's ruling
+ rejects by name.
+
+ **A URL that names no user gets nothing, deliberately.** `auth` is not
+ constructible from a password alone, and inventing an empty username is
+ measurably worse than silence: `mongodb://db.internal:27017/app` carries no
+ credentials at all today, and would carry `{username:''}` — a guaranteed
+ handshake failure — if the arm injected regardless. Injection happens only
+ where the URL already declares authenticated intent, which is also exactly what
+ the composed branch has always done with the same input. Making that
+ contradictory pair (a bound `credentialsRef` beside a user-less URL) loud
+ belongs at the authoring door, where both halves are visible at once; it is
+ filed rather than guessed at here.
+
+ **Blast radius is exactly the broken class.** A datasource that binds no secret
+ reaches the client byte-for-byte as before, and the `options` passthrough keeps
+ arriving verbatim — the injected `auth` is merged into it, not assigned over
+ it.
+
+ The pin extends `__tests__/bound-secret-dsn-branches.test.ts` (the mysql half's
+ file) and asserts at the **client-construction seam**: every mongo assertion
+ reads `MongoClient`'s own resolved `credentials`, never the URL string the
+ factory built. That distinction is load-bearing — a test asserting
+ `buildMongoUrl`'s return value would have passed throughout this defect's life,
+ and the postgres arm passes the equivalent config-layer assertion while still
+ being broken one layer lower.
+- 72050cc: fix(service-datasource): a bound `external.credentialsRef` reaches the mysql client on the DSN branch instead of being dropped (#8696)
+
+
+
+ `DatasourceConnectionService` resolves a datasource's `external.credentialsRef`
+ to a cleartext secret and hands it to the driver factory as `spec.secret`. The
+ mysql arm then **threw it away** whenever `config.url` was present: the DSN
+ string became the whole knex `connection`, and the resolved credential reached
+ nothing. Measured on `origin/main`, driver `mysql`, `config.url`
+ `mysql://app@db.internal:3306/app`, secret bound:
+
+ ```text
+ knex connection: typeof=string value="mysql://app@db.internal:3306/app"
+ ```
+
+ **This is a broken binding, not a disclosure.** Since #8082 refuses a
+ `user:password@` userinfo at the publish door, a bare-username DSN plus a bound
+ secret is the *only* authorable URL shape for this driver — the exact shape the
+ connection form produces and the exact shape #8155's re-homing remedy tells
+ operators to write. Such a datasource therefore connected **unauthenticated**,
+ or failed with a driver-level auth error naming nothing about the binding, while
+ its Setup page showed a credential bound and the connect path reported success.
+ It is the declared-≠-enforced shape one layer below Prime Directive #10:
+ `MysqlConfigSchema.url` already states the contract this code failed to keep —
+ *"bind the secret … and it is injected at connect time. A bare username
+ (`user@host`) stays writable."*
+
+ **The fix hands mysql2 the DSN and the secret together** — `{ uri, password }`
+ rather than a hand-parsed URL. mysql2 keeps owning its own DSN grammar (no URL
+ parsing, no re-encoding, no second dialect of `mysql://…` in this repo), and its
+ merge gives the **explicit** key precedence, so the bound credential also wins
+ over a legacy password embedded in a stored pre-#8082 row — the precedence the
+ postgres arm's DSN branch already declares. Measured on mysql2 3.23.1, knex
+ 3.3.0 and pg 8.22.0.
+
+ A DSN with **nothing bound passes through unchanged**, as the bare string it has
+ always been, so the entire blast radius is datasources that bind a secret — the
+ ones that are broken today.
+
+ Two measured findings this change deliberately does **not** act on, each filed
+ on its own:
+
+ - **The mongodb arm is still open.** `buildMongoUrl`'s `if (explicit) return
+ explicit;` drops the bound secret the same way, so a mongo DSN datasource
+ still reaches `MongoClient` with an **empty** password. The remedy is not a URL
+ rewrite — `MongoClient`'s `auth` option injects beside an unmodified url, and
+ it wins over an embedded userinfo password (measured on mongodb 7.5.0) — but it
+ requires a username as well, and reading the url's userinfo username needs the
+ platform's own DSN grammar (`new URL()` rejects the multi-host form
+ `MongoConfigSchema` documents). `@objectstack/spec/data` exports the password
+ half of that grammar and no username half; adding one belongs beside it rather
+ than as a second copy of the userinfo boundaries here.
+ - **The postgres arm passes this assertion at the config layer and is broken one
+ layer below it.** `pg` merges `parse(connectionString)` **over** the explicit
+ `password`, so `{connectionString, password}` resolves to the DSN's own
+ (absent) password — effective `password: null`, measured on pg 8.22.0. Its
+ `if (url)` branch is not fixed by symmetry with this one; the two clients merge
+ in opposite directions, which is why each arm's precedence is measured rather
+ than assumed.
+- d70428a: A mysql datasource that declares TLS now gets it, on both branches of the arm and in the spelling `mysql2` can read (#8874).
+
+ Two defects with one cause — `buildMysqlConnection` resolved the TLS option and then handed it to a client that could not use it, or to nobody at all.
+
+ **A declared `ssl` was dropped on the DSN branch.** With a `config.url` present the arm returned before the resolved option could be attached, so a datasource that declared TLS **and** wrote a connection url negotiated none — declared, resolved, dropped, with no diagnostic — while the discrete-fields branch of the same arm carried it. Whether a connection was encrypted therefore depended on which branch of one arm the datasource happened to take. The postgres arm has honoured this case since #4410 with its reasoning written in-code, and the same argument holds here: `mysql2` reads a uri and the `ssl` option as separate channels, and keeps the explicit key.
+
+ **`ssl: true` was never a `mysql2` value.** Measured on mysql2 3.23.1, `new ConnectionConfig({ …, ssl: true })` throws `SSL profile must be an object, instead it's a boolean` — and `true` is exactly what a declared `ssl: { enabled: true }` with no certificate material resolves to, as does the `config.ssl` shorthand, whose schema is a boolean and so has no other authorable value. The branch that appeared to honour the declaration was therefore throwing on every connection acquisition for the commonest way of writing it. The resolved `true` is now translated to the empty-options object it is already documented to be short for (`{}`, which mysql2 normalises to `{ rejectUnauthorized: true }` — its own default for an object, not a verification policy chosen here). Certificate objects, `false`, and a stored profile name pass through untouched.
+
+ **What does not change.** The DSN branch returns an object instead of the bare connection string **only when a declared `ssl` actually resolved** (or a secret is bound, unchanged from #8696). A datasource that declared neither still gets the byte-identical string knex has always parsed for it. Where the switch does happen, knex's own parse of the string and mysql2's parse of the same value as `uri` were compared key-by-key (`host`/`port`/`user`/`password`/`database`/`charset`/`timezone`/`connectTimeout`/`flags`/`socketPath`/`multipleStatements`) across the bare-username, embedded-password, no-userinfo, portless, percent-encoded-username and query-parameter forms — identical in every case, and pinned as a test rather than measured once.
+
+ Nothing that declared no TLS moves, so the behaviour change is confined to the datasources that were already broken: the ones connecting in cleartext against their own metadata, and the ones that could not connect at all.
+- 0961065: fix(security): a bound `external.credentialsRef` reaches the postgres SERVER on the DSN branch, not just the knex config (#8873)
+
+ A postgres datasource whose `config.url` is a DSN and whose credential is bound
+ through `external.credentialsRef` (or the connection form's secret field) opened
+ its connection **with no password at all**. Not a disclosure — a broken binding,
+ of the fail-quietly kind: `DatasourceConnectionService` resolved the secret
+ fail-closed, the operator saw a bound credential and a datasource reporting
+ connected, and the handshake carried nothing.
+
+ **This arm was the one that looked correct.** It had an explicit secret branch
+ and a comment declaring the intent — *"For a DSN, a separately-supplied secret
+ overrides the embedded password"* — and it emitted
+ `{ connectionString: url, password: spec.secret }`, which passes any assertion
+ written against the factory's own output. `pg` discarded the credential one
+ layer lower:
+
+ ```js
+ // pg 8.22.0, lib/connection-parameters.js
+ if (config.connectionString) {
+ config = Object.assign({}, config, parse(config.connectionString))
+ }
+ ```
+
+ Two independent mechanisms destroyed it, either sufficient on its own. `parse()`
+ emits a `password` key for **every** url — `''` when the url carries no userinfo
+ password — and `Object.assign` copies that over the injected value, after which
+ `val('password', …)` falls through to `PGPASSWORD` and the defaults; and knex's
+ `setHiddenProperty` has already made `password` a non-enumerable own property of
+ `connectionSettings`, which `Object.assign` does not copy at all. Measured on pg
+ 8.22.0 + knex 3.3.0: `postgresql://app@db.internal:5432/app` with a secret bound
+ resolved to password `null`, and a stored pre-#8082 url embedding
+ `app:embedded-legacy@` resolved to `'embedded-legacy'` — the DSN beating the
+ credential an operator deliberately bound. Since #8082 refuses a
+ `user:password@` userinfo at the publish door, the credential-free DSN is the
+ only authorable URL shape for this driver, so this was the shape the connection
+ form produces.
+
+ **The remedy is a third shape, not either sibling's.** The clients merge a DSN
+ against explicit keys in opposite directions: `mysql2` lets the explicit key win
+ (`{ uri, password }`, #8875) and mongodb rides in `options.auth` beside an
+ untouched url (#9042), while `pg` lets the DSN win. So on the postgres DSN
+ branch — and only when a secret is bound — `connectionString` is gone: the arm
+ hands `pg` **pg's own parse of the url** (`pg-connection-string`, the client's
+ parser, so there is no second dialect of `postgresql://…` in this repo to drift
+ out of agreement) with the credential applied afterwards, where nothing
+ re-parses over it. Everything else resolves exactly as before, verified
+ key-by-key across the sslmode, unix-socket, `?options=`, credential-free,
+ embedded-password and no-userinfo forms.
+
+ The competing remedy — keep `connectionString` and splice the secret into the
+ userinfo — was measured and rejected on two counts: `pg-connection-string`
+ honours a `?password=` query parameter **over** userinfo, so a stored pre-#8337
+ row would still lose the bound secret; and it would materialise the cleartext
+ credential into a string nothing hides (`JSON.stringify` of knex's
+ `connectionSettings` prints the whole DSN, while a discrete `password` stays
+ hidden), re-creating at connect time the hardest-to-redact credential spelling
+ that #8082 refuses to let anyone author.
+
+ **What changes for an existing deployment.** A DSN datasource that binds no
+ secret is byte-for-byte unaffected — it still hands `pg` the url unparsed. One
+ behaviour worth knowing: a stored pre-#8082 row that embeds a password in its
+ url *and* binds a credential now authenticates with the **bound** credential,
+ which is the precedence this arm's own comment always claimed and both sibling
+ arms already apply. A DSN naming no user still receives the credential (unlike
+ the mongodb arm's deliberate no-op there): `pg` sends a password only when the
+ server asks for one, so injecting cannot break a datasource that connects today.
+ Finally, a url `pg`'s own parser rejects (a multi-host DSN, which node-postgres
+ does not implement) is now refused when the driver is built rather than on first
+ query — the same error, named and located, with the url deliberately not echoed
+ because it may itself embed a credential.
+- 05864fb: Datasource-admin HTTP routes now require the `manage_platform_settings` capability, not merely authentication.
+
+ All eleven routes under `/api/v1/datasources` — list, read, driver catalog, remote-table
+ introspection, connection probes, credential migration, create, patch and remove — answer
+ `403 PERMISSION_DENIED` to a caller that resolves to an identity holding no
+ `manage_platform_settings` grant. The anonymous floor is unchanged (`401 UNAUTHENTICATED`).
+
+ The capability is matched to what the adjacent Setup-admin families already gate on, not
+ minted: `@objectstack/service-settings`'s platform-infrastructure namespaces (`mail`,
+ `storage`, `sms`, `auth`, `ai`, `knowledge`) declare it for reads and writes alike, and this
+ service's own Setup nav entry already declared `requiredPermissions:
+ ['manage_platform_settings']` for the console door in front of these routes. There is no
+ read/write split for the same reason those namespaces have none: a datasource read returns
+ stored connection configuration and live remote-schema introspection.
+
+ Impact: `admin_full_access` carries `manage_platform_settings`, so platform admins are
+ unaffected. A deployment that granted non-admin users access to Setup → Datasources through
+ some other capability must now grant `manage_platform_settings` (or bind those users to a
+ permission set carrying it).
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2d0af57]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/services/service-datasource/package.json b/packages/services/service-datasource/package.json
index 362f19c438..7ad826c94d 100644
--- a/packages/services/service-datasource/package.json
+++ b/packages/services/service-datasource/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-datasource",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "The datasource service (ADR-0015): external-table federation (introspect/draft/import/validate) + runtime UI datasource lifecycle (list/test/create/update/remove + REST routes). Open-source mechanism; the tier line falls on which ICryptoProvider / driver factory a host injects.",
"type": "module",
diff --git a/packages/services/service-i18n/CHANGELOG.md b/packages/services/service-i18n/CHANGELOG.md
index 5bc611cd96..78bb59df2b 100644
--- a/packages/services/service-i18n/CHANGELOG.md
+++ b/packages/services/service-i18n/CHANGELOG.md
@@ -1,5 +1,198 @@
# @objectstack/service-i18n
+## 17.1.0
+
+### Patch Changes
+
+- 0425db9: Published READMEs link to the docs site in the one form that works on npm, on GitHub and on the docs site (#9632)
+
+ **Seven docs links in these READMEs pointed nowhere.** They were spelled as a repo
+ path rooted at `/` — `[Flows](/content/docs/automation/flows.mdx)` — and a README in a
+ package's `files` array with `private` unset is rendered on the **npm package page** and
+ on **GitHub**, not only in this repository. There a root-relative href resolves against
+ `npmjs.com` and `github.com` respectively. It was not a docs-site route either:
+ `apps/docs/lib/source.ts` mounts `loader({ baseUrl: '/docs' })` over `content/docs`, so
+ the route for that first link is `/docs/automation/flows`, and `apps/docs/redirects.mjs`
+ carries no `/content` source that would rescue the written form. Every target page
+ existed and every one of them was reachable — only the links were not.
+
+ All seven now use the absolute form the repo had already established in
+ `create-objectstack`'s published READMEs: `https://docs.objectstack.ai/docs/...`, with
+ the path taken under `content/docs` and the page extension dropped, because the route
+ carries none. Each target was re-verified at the route level rather than as a file — the
+ two that named a **directory** (`/content/docs/automation/`,
+ `/content/docs/references/automation/`) resolve only because those directories carry an
+ `index.mdx`; a directory without one is a 404, not a section.
+
+ **Two more links in the same class were converted in the same pass.**
+ `service-knowledge` and `knowledge-ragflow` pointed at
+ `../../../content/docs/protocol/knowledge.mdx`. Those relative paths do resolve on both
+ GitHub and npm, so they are a milder defect than the seven — but they land the reader on
+ **raw MDX source** instead of the rendered page. They now point at the rendered page as
+ well. `service-knowledge`'s link text changed with it: it was the source filename in a
+ code span, which stops being an honest label once the destination is the page.
+
+ No API, behaviour or type surface changes — this is the published documentation these
+ packages ship.
+- f01c0ee: docs: five published service READMEs stop documenting an API that does not exist (#9532)
+
+ A version bump is the point, not a side effect: these five READMEs are in their
+ packages' `files` arrays with `private` unset, so they are the pages npm renders —
+ and a docs-only fix with no bump never reaches npm at all.
+
+ Each of the five told a reader to an import of a `Service…` class from its own package
+ and call a static `.configure({...})` on it. Neither has ever existed: no class in
+ this repo exposes a static `configure`, and none of `ServiceAnalytics`,
+ `ServiceAutomation`, `ServiceCache`, `ServiceI18n` or `ServiceJob` is exported by
+ anything. A reader following any of them wrote code that could not compile. The real
+ entry point in every case is a kernel plugin constructed with `new`:
+ `AnalyticsServicePlugin`, `AutomationServicePlugin`, `CacheServicePlugin`,
+ `I18nServicePlugin`, `JobServicePlugin`.
+
+ ⛔ A name swap alone would not have been enough, and the gate landed in #9546 is what
+ proves it: substituting the genuine class while keeping `.configure(...)` turns the
+ import finding into a call-site finding rather than into silence. Each README is
+ rewritten against the package's built type surface, and each package's entry is
+ deleted from `scripts/published-readme-exports.baseline.json` in the same change
+ (the baseline is reconciled in both directions, so a stale entry fails too).
+
+ What was removed as fabricated, beyond the entry point:
+
+ - **service-analytics** — a nine-endpoint REST surface (`/analytics/count`, `/sum`,
+ `/avg`, `/min`, `/max`, `/group-by`, `/time-series`, `/metrics`, `/metrics/:name`)
+ of which none exists; the real surface is `POST /analytics/query`,
+ `GET /analytics/meta`, `POST /analytics/sql` and `POST /analytics/dataset/query`.
+ Also removed: `defineMetric`, `getMetric`, `compare`, `funnel`,
+ `executeDashboard`, `invalidateCache`, and an `AnalyticsServiceConfig` block whose
+ four keys (`defaultDriver`, `enableCaching`, `cacheTTL`, `maxMemoryResults`) are
+ none of the real ones.
+ - **service-automation** — `executeFlow`/`getFlow`/`listFlows`/`getFlowHistory`/
+ `registerTrigger` as the contract (the real contract is `execute(flowName, context?)`
+ plus `listFlows()` and a set of optional members), and a five-endpoint REST list that
+ matches no mounted route. The flow-authoring half of that README was already accurate
+ and is kept.
+ - **service-cache** — `mget`/`mset`/`del`/`delPattern`/`namespace`/`ttl`/`expire`/
+ `persist`/`incr`/`incrby`/`decr`/`getOrSet`/`invalidateTag`/`resetStats`, none of
+ which exist; `ICacheService` has six members. `CacheStats.keys`/`hitRate` corrected to
+ `keyCount` (there is no `hitRate`), and `set(key, value, { ttl })` corrected to the
+ real positional `set(key, value, ttl?)` in seconds.
+ - **service-i18n** — an `await i18n.t('ns:key')` dialect with namespaces, plural
+ suffixes, `context`, `returnObjects`, `setLocale`/`getLocale`, `formatDate`/
+ `formatNumber`/`formatRelative`, `addLocale`/`removeLocale`/`reload`, `getCoverage`/
+ `getMissingKeys`, and a `{{lng}}/{{ns}}` file layout. The real `t()` is synchronous
+ and takes the locale positionally — `t(key, locale, params?)` — over one
+ `{locale}.json` file per locale. The `POST /i18n/translate` endpoint does not exist.
+ - **service-job** — `scheduleInterval`/`scheduleOnce`/`getJob`/`stopJob`/`resumeJob`/
+ `deleteJob`/`runNow`/`getJobHistory`/`clearHistory`/`getLastExecution`, and a
+ `schedule({ name, schedule, handler })` options-object call. The real `schedule` is
+ positional — `schedule(name, schedule, handler, options?)` — and returns `void`.
+ Retry defaults corrected to the enforced ones (`maxRetries: 0`,
+ `backoffMultiplier: 1`).
+
+ Two capability claims are corrected rather than deleted, because the source is what
+ decides:
+
+ - **service-cache** advertised Redis as production support. `RedisCacheAdapter` throws
+ `RedisCacheAdapter not yet implemented` from every method, and
+ `new CacheServicePlugin({ adapter: 'redis' })` throws during `init` rather than
+ falling back to memory. The README now says so at the top and points at registering
+ a custom `ICacheService` under the slot instead.
+ - **service-job**'s `adapter: 'interval'` stores cron registrations that never fire.
+ That is now stated in the adapter table rather than left for a reader to discover.
+
+ No compliance claim (SOC 2 / HIPAA / GDPR or similar) was found in any of the five —
+ the shape that raised `plugin-audit`'s severity in #9517 is absent here.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2d0af57]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json
index 804ff9b8f6..0990e19c4e 100644
--- a/packages/services/service-i18n/package.json
+++ b/packages/services/service-i18n/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-i18n",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "I18n Service for ObjectStack — implements II18nService with file-based locale loading",
"type": "module",
diff --git a/packages/services/service-job/CHANGELOG.md b/packages/services/service-job/CHANGELOG.md
index 78d83867ac..92816d5012 100644
--- a/packages/services/service-job/CHANGELOG.md
+++ b/packages/services/service-job/CHANGELOG.md
@@ -1,5 +1,305 @@
# @objectstack/service-job
+## 17.1.0
+
+### Patch Changes
+
+- 0425db9: Published READMEs link to the docs site in the one form that works on npm, on GitHub and on the docs site (#9632)
+
+ **Seven docs links in these READMEs pointed nowhere.** They were spelled as a repo
+ path rooted at `/` — `[Flows](/content/docs/automation/flows.mdx)` — and a README in a
+ package's `files` array with `private` unset is rendered on the **npm package page** and
+ on **GitHub**, not only in this repository. There a root-relative href resolves against
+ `npmjs.com` and `github.com` respectively. It was not a docs-site route either:
+ `apps/docs/lib/source.ts` mounts `loader({ baseUrl: '/docs' })` over `content/docs`, so
+ the route for that first link is `/docs/automation/flows`, and `apps/docs/redirects.mjs`
+ carries no `/content` source that would rescue the written form. Every target page
+ existed and every one of them was reachable — only the links were not.
+
+ All seven now use the absolute form the repo had already established in
+ `create-objectstack`'s published READMEs: `https://docs.objectstack.ai/docs/...`, with
+ the path taken under `content/docs` and the page extension dropped, because the route
+ carries none. Each target was re-verified at the route level rather than as a file — the
+ two that named a **directory** (`/content/docs/automation/`,
+ `/content/docs/references/automation/`) resolve only because those directories carry an
+ `index.mdx`; a directory without one is a 404, not a section.
+
+ **Two more links in the same class were converted in the same pass.**
+ `service-knowledge` and `knowledge-ragflow` pointed at
+ `../../../content/docs/protocol/knowledge.mdx`. Those relative paths do resolve on both
+ GitHub and npm, so they are a milder defect than the seven — but they land the reader on
+ **raw MDX source** instead of the rendered page. They now point at the rendered page as
+ well. `service-knowledge`'s link text changed with it: it was the source filename in a
+ code span, which stops being an honest label once the destination is the page.
+
+ No API, behaviour or type surface changes — this is the published documentation these
+ packages ship.
+- 73010f1: fix(services): the `DbJobAdapter` class JSDoc stops promising a `sys_job_run` row that `recordRuns: false` never writes (#9631)
+
+ `tsup` emits this comment into `packages/services/service-job/dist/index.d.ts`, so it is
+ the class-level editor tooltip an npm consumer of `@objectstack/service-job` reads. Its
+ third "persisted side effects" bullet said **every execution writes a `sys_job_run` row**;
+ `wrap()` gates that insert on `recordRuns`, which defaults to `true` but writes nothing at
+ all when set to `false`. The same emitted `index.d.ts` states the truthful field-level rule
+ for `recordRuns` sixty lines above, so the published declaration disagreed with itself
+ about one flag — a reader hovering either one got a different answer.
+
+ No runtime behaviour changes. This is a patch because the entire deliverable is text inside
+ a published package's `.d.ts`: with no version bump the corrected tooltip never reaches npm
+ and the fix is unmet in the only channel it is about.
+
+ The corrected bullet defers to `DbJobAdapterOptions.recordRuns` via `{@link}` rather than
+ restating the rule, so the two cannot drift apart again, and it names the one row the flag
+ does not govern — `replay()`'s synthetic `trigger: 'replay'` row, written either way. The
+ fourth bullet gains the matching negative: the `sys_job` counters are bumped
+ unconditionally, `recordRuns` gating only the per-attempt rows.
+
+ Five cases now pin the flag in both directions. Nothing in this package referenced
+ `recordRuns` before, so both corrected sentences were accurate but unenforced.
+- 52182a6: fix(services): `DbJobAdapter.replay()` honours `recordRuns` — an operator who switched run history off stops accumulating replay rows (#9633)
+
+ `recordRuns` is the on/off switch for `sys_job_run` history, and it had exactly
+ two `startRun` call sites. The gate landed on one of them: `wrap()`'s
+ per-attempt row was gated, `replay()`'s synthetic row was not. So a deployment
+ that set `recordRuns: false` wrote nothing for any scheduled or triggered
+ execution and **one complete row for every replay** — a table the operator
+ believes is switched off, filling slowly and exclusively with `trigger: 'replay'`
+ rows, the least representative sample of a job's history and one with no
+ non-replay rows beside it for context.
+
+ The carve-out was never designed. `replay()`'s synthetic row exists to force the
+ `trigger: 'replay'` tag that `IJobService.trigger` cannot carry back; the flag
+ simply arrived later and landed on one of the two writers. It is closed rather
+ than documented: one flag, one meaning, no second de-facto rule at a call site.
+ If an operator-initiated replay needs to be auditable when routine history is
+ off, the principled home for that is `sys_audit_log` — which has its own opt-in,
+ writer and retention — not an exception to a history switch.
+
+ **Behaviour change, user-visible:** with `recordRuns: false`, `replay()` now
+ writes no `sys_job_run` row. The handler still executes, and `sys_job`'s own
+ `last_run_at` / `last_status` / `run_count` / `failure_count` counters still
+ update — the flag has never gated those. With the default (`true`) nothing
+ changes: the synthetic row is still written, still tagged `trigger: 'replay'`,
+ and still carries the terminal status read off the inner execution.
+
+ All three of `replay()`'s arms are gated, not just the insert — the terminal
+ status arm, the success arm and the catch arm — so the flag cannot leave a
+ dangling `running` half-row with no `completed_at`, which would be worse than
+ either original behaviour.
+- c07d6e8: fix(services): two published `.d.ts` JSDoc comments stop describing behaviour their code does not have — `MemoryCacheAdapter` eviction is FIFO, not LRU, and `recordRuns` is an on/off switch, not a retention cap (#9611)
+
+ Both comments are emitted by `tsup` into each package's built `index.d.ts`, so they
+ are **the editor tooltip an npm consumer sees** — the same "published documentation
+ asserting behaviour the runtime does not have" class as #9517 and #9532, in a third
+ channel that no gate reads. No runtime behaviour changes in either package; this is a
+ patch because the corrected text only reaches consumers through a release.
+
+ **1. `MemoryCacheAdapter` — "LRU-style eviction" was never LRU.**
+
+ The class comment advertised `TTL-based expiry and LRU-style eviction`. The eviction
+ path takes `this.store.keys().next().value` — the first key in `Map` insertion order —
+ and `get()` returns `entry.value` without ever deleting and re-setting the key, so a
+ read does not move an entry back. Nor does an overwrite: `Map.set` on a key already
+ present keeps its original insertion slot. Eviction has therefore always been
+ **oldest-inserted (FIFO)**, which is a materially different hit-rate profile from the
+ one the tooltip promised anyone sizing a cache.
+
+ The comment was corrected rather than the code, deliberately: `maxSize` defaults to `0`
+ (unlimited), so the eviction path is off by default and nothing shipped is getting FIFO
+ where it expected LRU, and there is no measured pull for LRU. Minting a real behaviour
+ change to make a stale sentence true inverts the fix — the defect is that the
+ documentation lies, not that the cache is wrong. (A real LRU already exists in the repo,
+ `packages/metadata/src/utils/lru-cache.ts`, for the callers that need one.)
+
+ Four tests now pin the corrected sentence so it stops being an unenforced claim. Each is
+ written as a **discriminator against LRU**: it reads (or overwrites) the oldest entry
+ before overflowing the cache and then asserts that entry was evicted anyway — a hot key
+ dies on age, an untouched newer key survives. The pre-existing eviction tests could not
+ tell the two policies apart, which is how the wrong comment sat green.
+
+ **2. `DbJobAdapterOptions.recordRuns` — the comment described a different field.**
+
+ `/** Soft cap on sys_job_run rows recorded per job (defaults to none — handled by
+ retention jobs) */` made three claims and the code contradicts all three: the field is a
+ `boolean`, not a count; it defaults to `true`, not "none" (`args.options?.recordRuns ??
+ true`); and it gates whether a `sys_job_run` row is written at all, rather than being
+ trimmed later by retention. The sentence reads as if it belongs to the numeric
+ `JobRunRetention` knob that ADR-0057 retired — a copy-paste that outlived its source.
+
+ The consequence the new wording keeps in sight: **a reader who sets `recordRuns: false`
+ expecting "no cap" gets run history switched off.** The replacement states the real
+ meaning (one row per attempt, inserted at start and updated on settle, default `true`)
+ and both things that are *not* affected by the flag — the `sys_job` row's own
+ `last_status` / `run_count` / `failure_count` counters, which `bumpJob` updates
+ regardless, and `replay()`, which writes its synthetic `trigger: 'replay'` row without
+ consulting the flag at all.
+- f01c0ee: docs: five published service READMEs stop documenting an API that does not exist (#9532)
+
+ A version bump is the point, not a side effect: these five READMEs are in their
+ packages' `files` arrays with `private` unset, so they are the pages npm renders —
+ and a docs-only fix with no bump never reaches npm at all.
+
+ Each of the five told a reader to an import of a `Service…` class from its own package
+ and call a static `.configure({...})` on it. Neither has ever existed: no class in
+ this repo exposes a static `configure`, and none of `ServiceAnalytics`,
+ `ServiceAutomation`, `ServiceCache`, `ServiceI18n` or `ServiceJob` is exported by
+ anything. A reader following any of them wrote code that could not compile. The real
+ entry point in every case is a kernel plugin constructed with `new`:
+ `AnalyticsServicePlugin`, `AutomationServicePlugin`, `CacheServicePlugin`,
+ `I18nServicePlugin`, `JobServicePlugin`.
+
+ ⛔ A name swap alone would not have been enough, and the gate landed in #9546 is what
+ proves it: substituting the genuine class while keeping `.configure(...)` turns the
+ import finding into a call-site finding rather than into silence. Each README is
+ rewritten against the package's built type surface, and each package's entry is
+ deleted from `scripts/published-readme-exports.baseline.json` in the same change
+ (the baseline is reconciled in both directions, so a stale entry fails too).
+
+ What was removed as fabricated, beyond the entry point:
+
+ - **service-analytics** — a nine-endpoint REST surface (`/analytics/count`, `/sum`,
+ `/avg`, `/min`, `/max`, `/group-by`, `/time-series`, `/metrics`, `/metrics/:name`)
+ of which none exists; the real surface is `POST /analytics/query`,
+ `GET /analytics/meta`, `POST /analytics/sql` and `POST /analytics/dataset/query`.
+ Also removed: `defineMetric`, `getMetric`, `compare`, `funnel`,
+ `executeDashboard`, `invalidateCache`, and an `AnalyticsServiceConfig` block whose
+ four keys (`defaultDriver`, `enableCaching`, `cacheTTL`, `maxMemoryResults`) are
+ none of the real ones.
+ - **service-automation** — `executeFlow`/`getFlow`/`listFlows`/`getFlowHistory`/
+ `registerTrigger` as the contract (the real contract is `execute(flowName, context?)`
+ plus `listFlows()` and a set of optional members), and a five-endpoint REST list that
+ matches no mounted route. The flow-authoring half of that README was already accurate
+ and is kept.
+ - **service-cache** — `mget`/`mset`/`del`/`delPattern`/`namespace`/`ttl`/`expire`/
+ `persist`/`incr`/`incrby`/`decr`/`getOrSet`/`invalidateTag`/`resetStats`, none of
+ which exist; `ICacheService` has six members. `CacheStats.keys`/`hitRate` corrected to
+ `keyCount` (there is no `hitRate`), and `set(key, value, { ttl })` corrected to the
+ real positional `set(key, value, ttl?)` in seconds.
+ - **service-i18n** — an `await i18n.t('ns:key')` dialect with namespaces, plural
+ suffixes, `context`, `returnObjects`, `setLocale`/`getLocale`, `formatDate`/
+ `formatNumber`/`formatRelative`, `addLocale`/`removeLocale`/`reload`, `getCoverage`/
+ `getMissingKeys`, and a `{{lng}}/{{ns}}` file layout. The real `t()` is synchronous
+ and takes the locale positionally — `t(key, locale, params?)` — over one
+ `{locale}.json` file per locale. The `POST /i18n/translate` endpoint does not exist.
+ - **service-job** — `scheduleInterval`/`scheduleOnce`/`getJob`/`stopJob`/`resumeJob`/
+ `deleteJob`/`runNow`/`getJobHistory`/`clearHistory`/`getLastExecution`, and a
+ `schedule({ name, schedule, handler })` options-object call. The real `schedule` is
+ positional — `schedule(name, schedule, handler, options?)` — and returns `void`.
+ Retry defaults corrected to the enforced ones (`maxRetries: 0`,
+ `backoffMultiplier: 1`).
+
+ Two capability claims are corrected rather than deleted, because the source is what
+ decides:
+
+ - **service-cache** advertised Redis as production support. `RedisCacheAdapter` throws
+ `RedisCacheAdapter not yet implemented` from every method, and
+ `new CacheServicePlugin({ adapter: 'redis' })` throws during `init` rather than
+ falling back to memory. The README now says so at the top and points at registering
+ a custom `ICacheService` under the slot instead.
+ - **service-job**'s `adapter: 'interval'` stores cron registrations that never fire.
+ That is now stated in the adapter table rather than left for a reader to discover.
+
+ No compliance claim (SOC 2 / HIPAA / GDPR or similar) was found in any of the five —
+ the shape that raised `plugin-audit`'s severity in #9517 is absent here.
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/services/service-job/package.json b/packages/services/service-job/package.json
index 9c28f18ca4..770bf5bc54 100644
--- a/packages/services/service-job/package.json
+++ b/packages/services/service-job/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-job",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Job Service for ObjectStack — implements IJobService with setInterval and cron scheduling",
"type": "module",
diff --git a/packages/services/service-knowledge/CHANGELOG.md b/packages/services/service-knowledge/CHANGELOG.md
index e410185bab..164fc48035 100644
--- a/packages/services/service-knowledge/CHANGELOG.md
+++ b/packages/services/service-knowledge/CHANGELOG.md
@@ -1,5 +1,126 @@
# @objectstack/service-knowledge
+## 17.1.0
+
+### Patch Changes
+
+- 0425db9: Published READMEs link to the docs site in the one form that works on npm, on GitHub and on the docs site (#9632)
+
+ **Seven docs links in these READMEs pointed nowhere.** They were spelled as a repo
+ path rooted at `/` — `[Flows](/content/docs/automation/flows.mdx)` — and a README in a
+ package's `files` array with `private` unset is rendered on the **npm package page** and
+ on **GitHub**, not only in this repository. There a root-relative href resolves against
+ `npmjs.com` and `github.com` respectively. It was not a docs-site route either:
+ `apps/docs/lib/source.ts` mounts `loader({ baseUrl: '/docs' })` over `content/docs`, so
+ the route for that first link is `/docs/automation/flows`, and `apps/docs/redirects.mjs`
+ carries no `/content` source that would rescue the written form. Every target page
+ existed and every one of them was reachable — only the links were not.
+
+ All seven now use the absolute form the repo had already established in
+ `create-objectstack`'s published READMEs: `https://docs.objectstack.ai/docs/...`, with
+ the path taken under `content/docs` and the page extension dropped, because the route
+ carries none. Each target was re-verified at the route level rather than as a file — the
+ two that named a **directory** (`/content/docs/automation/`,
+ `/content/docs/references/automation/`) resolve only because those directories carry an
+ `index.mdx`; a directory without one is a 404, not a section.
+
+ **Two more links in the same class were converted in the same pass.**
+ `service-knowledge` and `knowledge-ragflow` pointed at
+ `../../../content/docs/protocol/knowledge.mdx`. Those relative paths do resolve on both
+ GitHub and npm, so they are a milder defect than the seven — but they land the reader on
+ **raw MDX source** instead of the rendered page. They now point at the rendered page as
+ well. `service-knowledge`'s link text changed with it: it was the source filename in a
+ code span, which stops being an honest label once the destination is the page.
+
+ No API, behaviour or type surface changes — this is the published documentation these
+ packages ship.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Patch Changes
diff --git a/packages/services/service-knowledge/package.json b/packages/services/service-knowledge/package.json
index 8f2427ed2b..2fabe783f6 100644
--- a/packages/services/service-knowledge/package.json
+++ b/packages/services/service-knowledge/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-knowledge",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Knowledge Service for ObjectStack — orchestrator implementing IKnowledgeService over pluggable IKnowledgeAdapter backends (RAGFlow, LlamaIndex, Dify, in-memory).",
"type": "module",
diff --git a/packages/services/service-messaging/CHANGELOG.md b/packages/services/service-messaging/CHANGELOG.md
index 8ec055ee8a..985821d779 100644
--- a/packages/services/service-messaging/CHANGELOG.md
+++ b/packages/services/service-messaging/CHANGELOG.md
@@ -1,5 +1,178 @@
# @objectstack/service-messaging
+## 17.1.0
+
+### Minor Changes
+
+- 23abe27: 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.
+- d31785f: feat(automation): flow `notify` nodes can reference an email template for localized delivery — `template` + `templateData` on `NotifyNodeConfig`, resolved by `(name, recipient locale)` at delivery time (#9205)
+
+ Ruled 「立项,走 emailTemplates 路线」: instead of widening the `flows`
+ translation surface (whose guidance excludes notification text, #7646), a
+ `notify` node now bridges to the existing localized email-template subsystem.
+
+ - **Spec** — `NotifyConfigSchema` gains `template` (a `sys_email_template`
+ name, read raw like `topic`/`channels`) and `templateData` (render context
+ for the template's `{{var}}` holes; values interpolate `{token}` templates
+ per run) as the localizable alternative to inline `title`/`message`. Inline
+ strings stay fully valid and byte-identical for existing flows — they are
+ the non-localizable path, and the describes now say so. A node carrying BOTH
+ paths, or `templateData` without `template`, or NEITHER path, is refused
+ loudly with the fix in the message (the `objectNavTargetExclusivity`
+ posture: unrepresentable over silent precedence).
+ - **service-automation** — the notify executor forwards the template
+ reference and its interpolated render context in the emit payload (the
+ outbox snapshots it onto each delivery row), and no longer demands an
+ inline title when a template is referenced.
+ - **service-messaging** — the email channel routes a template-carrying
+ delivery through `IEmailService.sendTemplate({ template, locale, data })`,
+ resolving the recipient locale per delivery: `payload.locale` if the
+ producer set one, else the deployment default
+ (`II18nService.getDefaultLocale()`, the #8195 ruled source), else
+ `sendTemplate`'s documented `en-US` ladder. Template-resolution failures
+ (`TEMPLATE_NOT_FOUND` / `TEMPLATE_INACTIVE` / `MISSING_VARIABLES`, and an
+ email service without `sendTemplate`) are graded `permanent` — dead
+ immediately with the code on the delivery row, instead of burning the retry
+ schedule on metadata that cannot fix itself.
+
+ The inbox channel keeps its existing rendering (notification title/body,
+ falling back to the topic on the template path): it has no locale-capable
+ rendering seam to the email-template subsystem today, and that gap is
+ documented in the PR rather than papered over with a duplicated resolver.
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2d0af57]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json
index bb6abf28e6..b472e583d2 100644
--- a/packages/services/service-messaging/package.json
+++ b/packages/services/service-messaging/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-messaging",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Messaging Service for ObjectStack — outbound notification dispatch (ADR-0012). Ships the MessagingChannel registry, emit() fan-out, and the always-on inbox channel; other channels (email/webhook/push/IM) plug in.",
"type": "module",
diff --git a/packages/services/service-package/CHANGELOG.md b/packages/services/service-package/CHANGELOG.md
index cf828cb095..9d5253e5b9 100644
--- a/packages/services/service-package/CHANGELOG.md
+++ b/packages/services/service-package/CHANGELOG.md
@@ -1,5 +1,151 @@
# @objectstack/service-package
+## 17.1.0
+
+### Patch Changes
+
+- e6e1de4: fix(rest): `DELETE /api/v1/packages/:id` answers a driver fault as a 5xx, and stops swallowing coded refusals (#8275)
+
+ `packageService.delete` swallowed every throw and reported failure by returning
+ a bare `{ success: false }`, so the door answered
+ `400 PACKAGE_DELETE_FAILED`. The statement behind it is
+ `DELETE FROM sys_packages WHERE id = ? [AND version = ?]`, so a missing table, a
+ lock timeout or a foreign-key restriction — a **server** fault — was answered as
+ a client error: it invited the caller to fix a request that was never the
+ problem, and it hid a real fault from every dashboard that buckets by status.
+
+ This is the sibling of what #8016 fixed on the throw path and #8131 fixed for
+ `publish`. `service-package` had been left **partially converted** by #8131 —
+ the same service answering two different classifications for the same kind of
+ fault — and this closes that.
+
+ **Two changes, both small:**
+
+ - `delete`'s catch re-throws a throw that **declares its own status**, so a
+ coded refusal reachable from this call path keeps the producer's status and
+ code through the door's #8016 mapping (a `409 DESTRUCTIVE_CHANGE` stays a
+ 409) instead of being flattened into one 400. It reuses the existing
+ `declaresHttpAnswer` predicate rather than declaring a second one.
+ - an undeclared throw stays a returned failure, and the door answers it **500**.
+
+ ⛔ The discriminant is the **status** channel, never `.code`. Every SQL driver
+ populates a string `code` on its errors (`ERR_SQLITE_ERROR`, `SQLITE_ERROR`, the
+ SQLSTATE `42P01`, `ER_NO_SUCH_TABLE`), so a `.code`-reading predicate re-throws
+ genuine driver faults as if they were refusals — resolving them to a `500
+ INTERNAL_ERROR` that carries the driver's own message. Pinned per dialect in
+ `delete-driver-fault.test.ts`, on this seam rather than inherited from
+ `publish`'s suite by analogy.
+
+ **4xx is not swept**, which is the other half of the fix: the
+ repeated-`?version=` refusal is checked before `delete` is called at all,
+ `PACKAGE_DELETE_PARTIAL` keeps its 400 (per-item uninstall failures are a
+ different outcome), a declared 4xx thrown from below keeps its own status and
+ code, and a declared 5xx keeps its own too.
+
+ **No message changed, and that is deliberate.** Unlike `publish`, this path
+ never disclosed anything: the door builds its sentence from the request's own
+ `:id` and `?version=`, and the producer returns a bare flag with **no message
+ channel at all**. Mirroring `publish`'s `driverFault` message here for symmetry
+ would have *created* a channel to the wire that nothing filters — the 5xx
+ withhold (#8086) lives in `sendThrownError`, which a returned failure never
+ reaches at any status. The new suites pin that absence from both sides: the
+ producer's returned shape has exactly one key, and the door answers its own
+ sentence even when handed a producer that grows a message.
+
+ Verified against a real `node:sqlite` database running the real statements from
+ `index.ts` — including a genuine foreign-key restriction, the fault family only
+ `DELETE` can have.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [b6c7690]
+- Updated dependencies [3851f87]
+- Updated dependencies [845e164]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [7fc01db]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/metadata-core@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/services/service-package/package.json b/packages/services/service-package/package.json
index efe42824ad..9c835484cd 100644
--- a/packages/services/service-package/package.json
+++ b/packages/services/service-package/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-package",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Package management service for ObjectStack — publish, install, and manage packages",
"type": "module",
diff --git a/packages/services/service-queue/CHANGELOG.md b/packages/services/service-queue/CHANGELOG.md
index 03920384d0..910e413adb 100644
--- a/packages/services/service-queue/CHANGELOG.md
+++ b/packages/services/service-queue/CHANGELOG.md
@@ -1,5 +1,108 @@
# @objectstack/service-queue
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/services/service-queue/package.json b/packages/services/service-queue/package.json
index c629a952b5..a6c869ac55 100644
--- a/packages/services/service-queue/package.json
+++ b/packages/services/service-queue/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-queue",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Queue Service for ObjectStack — implements IQueueService with in-memory and durable DB-backed (sys_job_queue) adapters",
"type": "module",
diff --git a/packages/services/service-realtime/CHANGELOG.md b/packages/services/service-realtime/CHANGELOG.md
index 452fc471c9..4c2293e591 100644
--- a/packages/services/service-realtime/CHANGELOG.md
+++ b/packages/services/service-realtime/CHANGELOG.md
@@ -1,5 +1,108 @@
# @objectstack/service-realtime
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Patch Changes
diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json
index 19e75c0b46..9fa6110fa7 100644
--- a/packages/services/service-realtime/package.json
+++ b/packages/services/service-realtime/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-realtime",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Realtime Service for ObjectStack — implements IRealtimeService with WebSocket and in-memory pub/sub",
"type": "module",
diff --git a/packages/services/service-settings/CHANGELOG.md b/packages/services/service-settings/CHANGELOG.md
index 1122e9ba50..12ad62de49 100644
--- a/packages/services/service-settings/CHANGELOG.md
+++ b/packages/services/service-settings/CHANGELOG.md
@@ -1,5 +1,176 @@
# @objectstack/service-settings
+## 17.1.0
+
+### Patch Changes
+
+- 7337f30: 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.
+- 501ed0e: fix(settings): the rotated-secret reaper verifies the repoint instead of inferring it (#8262)
+
+ `SettingsService.reapRotatedSecret` deleted the `sys_secret` row that
+ `upsertRow` reported as `previousEnc`, and inferred that the repoint it was
+ cleaning up after had taken effect from `previousEnc !== nextEnc`. That
+ inference holds for the shipped adapter, which forwards
+ `context: { isSystem: true }`. It does not hold for an adapter that drops
+ `context` — the reader `SettingsEngine`'s own doc comment contemplates, and a
+ documented extension point rather than a mistake nobody makes.
+
+ With `context` dropped, `sys_setting.value_enc` is `readonly: true` so the
+ UPDATE has it stripped, the row keeps naming the OLD handle, and the reaper
+ then deleted **the ciphertext still in force**: `materialiseRow` dereferenced a
+ dangling handle, got nothing, and the setting silently read as empty. That is
+ unrecoverable — the audit trail records digests, never handles or ciphertext,
+ so nothing can even name what was destroyed. Measured on the real engine over
+ the real `SysSetting` / `SysSecret` schemas, three writes gave `sys_secret`
+ `1 → 1 → 2` with `value_enc` pinned to a row that no longer existed.
+
+ The reaper now re-reads the row after the write and deletes `previousEnc` only
+ once storage confirms the row no longer names it. The criterion is
+ `current !== previousEnc` rather than the narrower `current === nextEnc`:
+ under a concurrent rotation the row may already have moved on to a third
+ handle, where `previousEnc` is genuinely unreferenced and the narrower test
+ would leak the orphan the reaping exists to prevent. Both refuse the case that
+ matters.
+
+ Every refusal branch (unreadable row, failed read, row still naming the
+ handle) leaves an orphan and logs — the recoverable direction, and the one an
+ orphan sweep can clean up; there is no recoverable direction on the other
+ side. The added read sits behind every cheap guard, so it is paid only where a
+ destructive delete would otherwise follow, and it is inside the same
+ best-effort guarantee as the delete: a rotation is never failed by it.
+
+ Latent rather than live: no shipped path reaches this, because the shipped
+ adapter forwards `context`. The population at risk is third-party and custom
+ `SettingsEngine` adapter authors — who also had no discovery path, since the
+ warning on `SettingsEngine.update` still described only the pre-#8063
+ consequence ("the rotated-away credential stays in force"). That warning now
+ states the real consequence, and a non-forwarding adapter announces itself in
+ the log instead of failing silently.
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2d0af57]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/services/service-settings/package.json b/packages/services/service-settings/package.json
index 2e217ca97c..d883f40592 100644
--- a/packages/services/service-settings/package.json
+++ b/packages/services/service-settings/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-settings",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Settings service for ObjectStack — manifest registry + K/V resolver (OS_* env > Tenant > User > Default) + REST routes. See ADR-0007.",
"type": "module",
diff --git a/packages/services/service-sms/CHANGELOG.md b/packages/services/service-sms/CHANGELOG.md
index 86cd9e951c..a7d1163140 100644
--- a/packages/services/service-sms/CHANGELOG.md
+++ b/packages/services/service-sms/CHANGELOG.md
@@ -1,5 +1,104 @@
# @objectstack/service-sms
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [445ae4d]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [7337f30]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [5ed8ee6]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/plugin-auth@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/services/service-sms/package.json b/packages/services/service-sms/package.json
index f1e3b83a99..daa2f7bd65 100644
--- a/packages/services/service-sms/package.json
+++ b/packages/services/service-sms/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-sms",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "SMS service for ObjectStack — ISmsService + transport-pluggable outbound delivery (Aliyun / Twilio / log).",
"main": "dist/index.js",
diff --git a/packages/services/service-storage/CHANGELOG.md b/packages/services/service-storage/CHANGELOG.md
index bcd8111ba3..9cd0d0d325 100644
--- a/packages/services/service-storage/CHANGELOG.md
+++ b/packages/services/service-storage/CHANGELOG.md
@@ -1,5 +1,167 @@
# @objectstack/service-storage
+## 17.1.0
+
+### Patch Changes
+
+- bbd86ed: Attachment access hooks: read the caller's org under the blessed `organizationId` name
+
+ `callerContext()` in the `sys_attachment` access kit built its fallback
+ execution envelope from `session.tenantId` — an alias removed from the
+ hook/action session surface in v11 (#3290). `HookContextSchema` strips a
+ `tenantId` key and the engine's `buildSession` only ever emits
+ `organizationId`, so on every call that reached the session fallback (no
+ execution context riding along) the envelope handed to
+ `ISharingService.canEdit` carried **no organization at all**. Parent-record
+ access for attachments was therefore evaluated without the caller's active
+ org on that path. It now reads `session.organizationId`, matching the
+ `sys_comment` kit, which already did.
+
+ The `sys_comment` kit's own `callerContext()` had the same read as a dead
+ first arm (`s.tenantId ?? s.organizationId`); the arm is removed. That half
+ is behaviour-neutral — the fallback already carried the value.
+
+ Both kits gain coverage of the session-fallback path in both directions: the
+ blessed name is read, and a stray removed-alias key does not become the org.
+- 593c4bf: feat(spec): `storage` becomes the canonical `CoreServiceName` slot; `file-storage` stays a deprecated v17 alias (#9683)
+
+
+
+ Maintainer ruling, 2026-08-18, verbatim: 「9683 file-storage 可以叫 storage」.
+ The `file-storage` slot was the only `CoreServiceName` member whose spelling
+ diverged from its documented accessor (`services.storage`), with no recorded
+ reason anywhere in the tree.
+
+ - `CoreServiceName` gains `storage` as the canonical member; `file-storage`
+ stays an accepted, deprecated alias within v17 (it is a published enum
+ member — existing `getService('file-storage')` callers keep working).
+ `CORE_SERVICE_PROVIDER` and `ServiceRequirementDef` carry both.
+ - `@objectstack/service-storage` registers the **same instance** under both
+ names (the `http.server` / `http-server` pattern), pinned by an
+ alias-equivalence test.
+ - Every internal consumer resolves `storage`: the HTTP dispatcher, the email
+ plugin's attachment store, and `os migrate files-to-references`. Discovery
+ reports the service under the canonical `storage` key and mirrors the row
+ verbatim under the `file-storage` key for the alias's v17 lifetime, so
+ existing discovery readers (e.g. the console endpoint catalog) keep
+ working.
+ - Docs (`kernel/runtime-services`, `kernel/contracts`) now document the
+ canonical slot; a custom v17 provider for this slot should register both
+ names.
+- 1258dca: Restore the #4757 unscoped multi-delete refusal on `sys_attachment` through the wired engine (#9719).
+
+ `ObjectQL.registerHook` gains an opt-in `dispatchUnscopedMultiDelete` declaration (valid on `beforeDelete` registrations only — anything else is refused at registration): when a `multi: true` delete arrives with no `where` at all (absent or `null`), the engine's predicate path dispatches the whole-operation context ONCE to declaring registrations — before any matched row is resolved, zero-match included — so a guard about the operation's shape can refuse it. Binding `input.id` on that context is refused (`HookTargetRebindError`, path `'unscoped-multi'`). Undeclared registrations, scoped deletes (including the match-all `where: {}`), and by-id deletes see no new dispatch.
+
+ The `sys_attachment` access guard declares the flag, so its documented refusal of a predicate-less multi-delete fires again with its declared envelope (`ATTACHMENT_DELETE_DENIED`, HTTP 403): since the per-row dispatch contract (#5038/#5574) that branch was unreachable, and a predicate-less `multi: true` delete quietly removed every row the caller happened to be entitled to. System-context and context-less programmatic deletes bypass the guard exactly as before.
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [2d0af57]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/observability@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/services/service-storage/package.json b/packages/services/service-storage/package.json
index 6190907fd7..932cd450ca 100644
--- a/packages/services/service-storage/package.json
+++ b/packages/services/service-storage/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-storage",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Storage Service for ObjectStack — implements IStorageService with local filesystem and S3 adapter skeleton",
"type": "module",
diff --git a/packages/spec/CHANGELOG.md b/packages/spec/CHANGELOG.md
index 3a5414de80..d1eb4e9ff0 100644
--- a/packages/spec/CHANGELOG.md
+++ b/packages/spec/CHANGELOG.md
@@ -1,5 +1,2828 @@
# @objectstack/spec
+## 17.1.0
+
+### Minor Changes
+
+- 07e630e: 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.
+- 2f65b1b: `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`".
+- 720ee95: fix(security): the shipped admin permission sets no longer grant export on the `*` wildcard (#8681)
+
+
+
+ **BREAKING for any deployment whose administrators export today.** Landing after
+ the v17.0.0 cut, so it ships as `minor` under the lockstep launch-window
+ convention; the migration prescription is registered under protocol major 18,
+ where `objectstack migrate meta` users will look.
+
+ `admin_full_access`, `organization_admin` and the derived
+ `organization_admin_no_bypass` shipped `objects['*'].allowExport = true`. That
+ single line made the 17.0 export axis **undeniable** for anyone holding an admin
+ set: an application could declare an object exportable by nobody, ship it, and
+ the platform would export it anyway.
+
+ Measured on 17.0.0 GA — 40 export probes, 5 principals, 8 objects, real Bearer
+ tokens — an org owner exported `crm_quote` (9 rows), `crm_campaign` (13) and
+ `crm_task` (15) with 200 and full data. No app permission set granted export on
+ any of the three, and the app had no way to say no:
+
+ 1. the wildcard lives in code-package metadata, so editing it answers
+ `403 [not_overridable] Metadata item 'permission/admin_full_access' is
+ provided by a code package`;
+ 2. the org admin holds no app-authored permission set, so there is nowhere to
+ author the per-object `allowExport: false` that would otherwise have won.
+
+ **This was never a gate defect.** The same run proves the export gate exact for
+ every other principal: a token refused on one object exports another on the same
+ route, granting `allowExport` at runtime flips 403 to 200, and revoking it flips
+ it back. A plain member carrying `'*': { allowExport: true }` exported too — the
+ wildcard was simply doing what it said. What changes is that the platform stops
+ shipping that grant.
+
+ This is #5491 applied to the export axis. That change removed `member_default`'s
+ CRUD wildcard because a wildcard in a set every principal resolves is not a
+ default but a floor no app can get under; the export wildcard survived by
+ omission rather than by decision, one tier up.
+
+ **Migration — grant `allowExport` explicitly in an app permission set where
+ admin export is intended.** There is no automatic replacement, deliberately:
+ which principals may take a bulk machine-readable copy of a table is the
+ segregation-of-duties judgement the axis exists to make explicit.
+
+ ```ts
+ // In YOUR app's permission set — not a platform set (those are not overridable).
+ {
+ name: 'system_admin',
+ objects: {
+ crm_account: { allowRead: true, allowExport: true }, // export intended
+ crm_quote: { allowRead: true }, // export withheld
+ },
+ }
+ ```
+
+ ⚠️ **Nothing fails at parse time, and the shipped sets are re-seeded on
+ upgrade.** A deployment that upgrades without editing anything is valid metadata
+ whose administrators have quietly lost export on every object no app set names —
+ the first sign is a support report, not an error. Verify behaviourally: sign in
+ as an org owner and call `GET /api/v1/data//export`, expecting 200 where
+ export is intended and 403 `EXPORT_NOT_PERMITTED` where it is not.
+
+ **What is deliberately unchanged.** READ is untouched — an admin still sees
+ every record they saw before; this narrows bulk egress only. `allowExport` on a
+ `'*'` entry remains a supported, honoured authoring shape in an app's own sets.
+ Specific-over-wildcard precedence is unchanged (an explicit per-object entry
+ still overrides the wildcard). The `viewAllRecords` / `modifyAllRecords`
+ super-user bits still do not imply export, exactly as before. And an app's own
+ admin set already gets precisely its declared posture — declared `false` answers
+ 403, declared `true` answers 200 — which is what makes withdrawing the platform
+ grant safe rather than merely restrictive.
+
+ Both admin sets are fixed together, and the org-admin pair from one declaration
+ (`organization_admin_no_bypass` is derived from `organization_admin`). Fixing
+ one and not the other was rejected outright: a half-closed export boundary reads
+ as closed and is not.
+- f287435: feat(spec): refuse undeclared keys on the analytics authoring surface (#4001 data batch D)
+
+ **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
+ launch-window convention ships it as `minor`; the migration prescription is
+ registered under protocol major 18, where `os migrate meta` users will look).
+
+ All 8 `data/analytics.zod.ts` sites are strict: the cube family (`CubeSchema` +
+ its `refreshKey` block, `MetricSchema` + its `filters[]` items,
+ `DimensionSchema`, `CubeJoinSchema`) and the query family
+ (`AnalyticsQuerySchema` + its `timeDimensions[]` items). Before this change an
+ undeclared key on any of them was silently dropped: a join authored with a
+ typo'd `relationship` registered with the `many_to_one` default — a different
+ join shape than the author declared — and a cube's misspelled key vanished
+ under a successful parse.
+
+ The subtle half is the query: `/analytics/query`'s TOP level has been strict
+ since #3878 (`AnalyticsQueryRequestSchema`), but top-level strictness does not
+ recurse — measured on `main`, `timeDimensions: [{ dimension, granuarity:
+ 'day' }]` rode through the strict wrapper with the typo silently stripped, so
+ the query bucketed the whole range as one group under an ordinary 200. The
+ nested item is now strict, and the base schema's own strictness makes the
+ posture hold at every door instead of only at the wrapper that re-applied it.
+
+ **What is refused:** any key the shape does not declare, with a prescriptive
+ message — the surface, the offending key, and a rename (`title` → `label` on a
+ metric/dimension, `label` → `title` on the cube, `table`/`sqlTable` → `sql`,
+ `granularity` → `granularities` on a dimension and the reverse on a query time
+ dimension, `orderBy` → `order`; `filters` on a query gets the `where`
+ prescription matching the dispatcher's #3878 hint).
+
+ **What stays accepted:** every declared key byte-identically, including the
+ `#3878` tombstones on the request wrapper (`query`/`format` still answer their
+ migration text).
+
+ ## FROM → TO
+
+ ```ts
+ // before — parsed green; the join fell back to many_to_one silently
+ defineCube({
+ name: 'orders', sql: 'orders',
+ measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } },
+ dimensions: {},
+ joins: { customers: { name: 'customers', sql: 'a.id = b.a_id', relationshipp: 'one_to_many' } },
+ })
+
+ // after — rejected with `relationshipp` → `relationship`; write the declared key
+ defineCube({
+ name: 'orders', sql: 'orders',
+ measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } },
+ dimensions: {},
+ joins: { customers: { name: 'customers', sql: 'a.id = b.a_id', relationship: 'one_to_many' } },
+ })
+ ```
+
+ There is deliberately no automatic rewrite: an undeclared key is either a
+ spelling of a declared one (the rejection names the rename) or names a
+ capability the analytics layer does not deliver, and blessing it would be
+ declared-but-unenforced surface (ADR-0078). `os migrate meta` surfaces the
+ change as a structured TODO (semantic entry
+ `analytics-authorable-unknown-keys-refused`, protocol major 18 — this refusal
+ is not part of the v17.0.0 cut).
+
+
+- 9aa8890: **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.
+
+
+- 7c9c1dd: 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.
+- 75b7c24: 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.
+
+
+- 8640fb2: `os validate`: a dashboard header `modal` action's target resolves against declared PAGES, only (#9013)
+
+ `validateDashboardActionRefs` resolved an `actionType: 'modal'` header button's
+ `actionUrl` the way objectui's `DashboardView` used to dispatch it: a defined
+ action name, a bare object name, or the `_` prefix form
+ (`create_`/`new_`/`add_`/`edit_`/`update_` + a defined object) all passed, and a
+ target naming a declared page ERRORED unless it collided with one of those.
+
+ That mirror is gone. Maintainer ruling objectstack#6739-A (2026-08-09): a
+ `type: 'modal'` string target names a PAGE, only — the spec TSDoc, the published
+ docs and `defineStack`'s cross-reference walk already said so, and objectui#4764
+ / objectui#4782 retired the renderer's object fallback and `DashboardView`'s
+ second copy of the prefix convention (enumerated across both repos' corpora:
+ zero producers). After that, `os validate` blessed exactly the buttons the
+ runtime refuses — the false affordance the rule exists to eliminate — while
+ refusing the one shape the runtime serves.
+
+ **BREAKING** accept-set change on the `os validate` gating tier (landing after
+ the v17.0.0 cut; the lockstep launch-window convention ships it as `minor`):
+
+ - A `modal` header target naming a defined action, a bare object, or a
+ `_` form now **fails** validation. Those buttons already
+ dispatch to a named refusal at runtime.
+ - A `modal` header target naming a declared page now **passes** — it was
+ wrongly refused before.
+
+ ## FROM → TO
+
+ ```ts
+ // before — passed validation; the runtime now refuses the click
+ header: {
+ actions: [{ label: 'New Deal', actionType: 'modal', actionUrl: 'create_opportunity' }],
+ }
+
+ // after — name a declared page…
+ header: {
+ actions: [{ label: 'Intake', actionType: 'modal', actionUrl: 'deal_intake' }], // pages: [{ name: 'deal_intake' }]
+ }
+ // …or, to open an object's form, use the validated first-class shape
+ header: {
+ actions: [{ label: 'New Deal', actionType: 'form', actionUrl: 'opportunity.edit' }],
+ }
+ ```
+
+ There is deliberately no automatic rewrite: a retired-shape target is a
+ name-shaped guess (`create_opportunity` names the page `create_opportunity`, or
+ it names nothing — the ruling explicitly declined keeping the prefix), and only
+ the author knows whether the button meant a page or an object form.
+ `objectstack migrate meta` surfaces the change as a structured TODO (semantic
+ entry `dashboard-header-modal-target-page-only`, protocol major 18).
+
+
+- 2420641: feat(spec): refuse a credential in the mongo options passthrough (`config.options.auth.password`) at publish (#9040)
+
+ **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
+ launch-window convention ships it as `minor`; the migration prescription is
+ registered under protocol major 18, where `os migrate meta` users will look).
+
+ The FOURTH spelling of the same inline secret: #7990 refused the top-level
+ `password` key, #8082 the URL userinfo (`user:password@host`), #8337 the
+ credential-bearing URL query parameters — and the MongoClient `options`
+ passthrough stayed open one syntax over.
+ `options: { auth: { username, password } }` parsed green, persisted the
+ password cleartext into `sys_metadata` (served back by the ordinary data API,
+ unredacted), and genuinely authenticated: measured on `mongodb@7.5.0`, the
+ client the driver spreads `config.options` into, the block is transformed into
+ `MongoCredentials` — so the workaround was live, not inert.
+
+ **What is refused** (write door, closed measured list
+ `MONGO_OPTIONS_CREDENTIAL_PATHS` behind `credentialFreeMongoOptions`, composed
+ with the #8336 placeholder refusal on the same slot): a NON-EMPTY STRING
+ `options.auth.password`, with the binder prescription — and the "wins over"
+ reassurance is true for this syntax: a bound `external.credentialsRef` secret
+ outranks the passthrough `auth` block at connect (#8696, measured).
+ Deliberately not refused, each measured: `auth.username` alone (#8876's
+ asymmetry — a username is not credential material), an empty password (the
+ passthrough twin of `user:@host`), every legitimate passthrough option
+ (`replicaSet`, `tls`, timeouts — byte-identical pins),
+ `authMechanismProperties.AWS_SESSION_TOKEN` (the v7 client itself throws on it
+ under MONGODB-AWS and nothing reads it otherwise), and the binder-slotless
+ client secrets (`proxyPassword`, `tlsCertificateKeyFilePassword`, `key`,
+ `passphrase`) — refusing those would name a remedy that does not exist (the
+ binder fills exactly one slot; the turso-`encryptionKey` posture, #8081
+ item 4).
+
+ **Read half** (additive, never the substitute — #8082's ruling): stored
+ passthrough secrets are now redacted on every read exit —
+ `options.auth.password` plus the binder-slotless names above and
+ `AWS_SESSION_TOKEN` — reported as dotted `redactedKeys`
+ (`options.auth.password`), which the metadata write door's generic
+ carry-forward already walks, so an untouched "Save" keeps the stored
+ credential on both admin doors (`restoreRedactedConfig` mirrors per leaf).
+ The #8155 credential-migration planner refuses a stored passthrough-credential
+ row with the per-row remedy instead of planning `nothing-to-migrate` over live
+ cleartext (dropping only the nested leaf would leave an `auth` block the
+ client refuses at construction, measured).
+
+ ## FROM → TO
+
+ ```yaml
+ # before — parsed green; password stored cleartext in sys_metadata and
+ # resolved into MongoCredentials at connect
+ driver: mongodb
+ config:
+ url: mongodb://app@mongo.internal:27017/events
+ options:
+ replicaSet: rs0
+ auth: { username: app, password: PLAINTEXT-IN-METADATA }
+
+ # after — rejected with the binder prescription; bind the secret instead
+ driver: mongodb
+ config:
+ url: mongodb://app@mongo.internal:27017/events
+ options:
+ replicaSet: rs0
+ external:
+ credentialsRef: sys_secret:01J9ZK4T2N # or the connection form's secret field
+ ```
+
+ There is deliberately no automatic rewrite: moving the value requires
+ encrypting it into `sys_secret` through a running secret binder, which a
+ source-file transform cannot do — and auto-dropping only the nested password
+ would leave an `auth` block the MongoDB client refuses outright.
+
+
+- 2ad91c3: 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.
+
+
+- f57fb38: feat(spec): refuse credential-bearing URL query parameters (`?authToken=` / `?password=`) in authored driver config at publish (#8337)
+
+ **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
+ launch-window convention ships it as `minor`; the migration prescription is
+ registered under protocol major 18, where `os migrate meta` users will look).
+
+ The third spelling of the same secret: #7990 refused the inline credential
+ keys, #8082 refused the URL userinfo form (`user:password@host`), and the
+ query string stayed open — `libsql://x.turso.io?authToken=eyJ…` persisted the
+ JWT cleartext into `sys_metadata` (served back by the ordinary data API) and,
+ measured against the clients this tree pins, actually authenticates:
+ `@libsql/core@0.17.4` assigns the URL's `?authToken=` OVER the config-level
+ token — so the workaround also silently defeated the binder-injected secret —
+ and `pg-connection-string@2.14.0` copies every query parameter into the client
+ config, `?password=` winning over userinfo.
+
+ **What is refused** (write door, shared value-level parse
+ `urlCredentialQueryParams` beside #8082's `urlUserinfoPassword`): turso
+ `config.url` / `config.syncUrl` carrying `?authToken=`, postgres `config.url`
+ carrying `?password=` — matched case-insensitively on the percent-decoded key,
+ non-empty values only, with the #8082-template prescription (datasource secret
+ binder / `external.credentialsRef`; runtime-environment DSNs are unaffected).
+ mysql and mongo URLs are deliberately NOT narrowed: both clients were measured
+ ignoring `?password=`, so refusing it would widen past the measured defect.
+
+ **What stays accepted:** every credential-free URL byte-identically, benign
+ query parameters (`?tls=`, `?sslmode=`, …) included, and the parameter-absent
+ shape the read path serves — which keeps an untouched "Save" on a legacy row
+ working.
+
+ **Read half** (the same PR, per the card): `redactDatasourceConfig` /
+ `getDatasource()` now strip credential query parameters from served URLs for
+ every driver (new `redactUrlCredentials` / `redactUrlCredentialQueryParams`
+ exports), `restoreRedactedConfig` mirrors the composite so an untouched
+ round-trip keeps the stored token, and the credential-migration planner
+ refuses a query-token row with the per-row remedy instead of planning
+ `nothing-to-migrate` over cleartext.
+
+ ## FROM → TO
+
+ ```yaml
+ # before — parsed green; JWT stored cleartext in sys_metadata, and at connect
+ # it silently overrode the binder-injected secret
+ driver: turso
+ config:
+ url: libsql://app-org.turso.io?authToken=eyJhbGciOiJFZERTQSJ9.x.y
+
+ # after — rejected with the binder prescription; bind the secret instead
+ driver: turso
+ config:
+ url: libsql://app-org.turso.io
+ external:
+ credentialsRef: sys_secret:01J9ZK4T2N # or the connection form's secret field
+ ```
+
+ There is deliberately no automatic rewrite: moving the value requires
+ encrypting it into `sys_secret` through a running secret binder, which a
+ source-file transform cannot do — stripping the parameter alone would silently
+ drop a live credential.
+
+
+- 00777a0: 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.
+
+
+- d491625: feat(spec): refuse the contradictory pair "`external.credentialsRef` bound + a mongo `config.url` naming no user" at publish (#9041)
+
+ **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
+ launch-window convention ships it as `minor`, like the sibling refusals #8337
+ and #9040; the migration prescription is registered under protocol major 18,
+ where `os migrate meta` users will look).
+
+ The "absence must be loud" half of the #8696 family, previously unserved:
+ after #8696 a mongo datasource that binds `external.credentialsRef` and
+ authors a `config.url` gets the secret injected as MongoClient `auth` — but
+ `auth` needs a username as well as a password, and with `url` present the only
+ place the username can come from is the URL's own userinfo. So the injection
+ is conditional on the URL naming a user:
+
+ - `mongodb://app@db.internal:27017/app` + bound secret → injected, correct;
+ - `mongodb://db.internal:27017/app` + bound secret → **nothing happens** — the
+ datasource connects anonymously and the operator is told nothing.
+
+ The second shape is a configuration that cannot work as written; it is now
+ refused at the datasource level (`DatasourceSchema`'s refinement — the one
+ door that sees both halves at once; a config-level refinement cannot, because
+ `credentialsRef` sits on the datasource and `url` inside `config`). The
+ refusal names BOTH valid authoring fixes without prescribing either: add the
+ username to the URL, or drop the binding.
+
+ **Scope fences, each measured**: mongodb arm only, legacy `driver: 'mongo'`
+ rows judged identically via `resolveDriverId` (the postgres arm injects on a
+ user-less DSN by its own measured mechanism, #8873, and is not assumed to
+ share the defect); "names no user" means `urlUserinfoUsername` answers
+ `undefined` — the present-but-empty userinfo forms already throw in
+ MongoClient itself (`MongoParseError: URI contained empty userinfo section`);
+ an empty-string `credentialsRef` is not a binding (mirrors the connect path's
+ truthy check); the composed branch (no `url`) is untouched — its discrete
+ `username` field is live. Injecting a fabricated empty username instead of
+ refusing was measured worse on mongodb@7.5.0: it turns a connection that works
+ anonymously today into a guaranteed handshake failure. Composes independently
+ with the sibling refusals (#8082 userinfo, #8336 placeholders, #9040 options
+ passthrough) — one artefact violating several reports each at its own path.
+
+ ## FROM → TO
+
+ ```yaml
+ # before — parsed green; the binding was a silent no-op and the datasource
+ # connected anonymously with the bound secret unused
+ driver: mongodb
+ config:
+ url: mongodb://mongo.internal:27017/events
+ external:
+ credentialsRef: sys_secret:01J9ZK4T2N
+
+ # after (authenticated intent) — name the user in the URL; the bound secret
+ # is injected at connect (#8696)
+ driver: mongodb
+ config:
+ url: mongodb://app@mongo.internal:27017/events
+ external:
+ credentialsRef: sys_secret:01J9ZK4T2N
+
+ # after (anonymous intent) — drop the binding that could never land
+ driver: mongodb
+ config:
+ url: mongodb://mongo.internal:27017/events
+ ```
+
+ There is deliberately no automatic rewrite: the two fixes are contradictory
+ intents — authenticate (add the username) versus anonymous (drop the binding)
+ — and choosing between them requires knowing what the datasource is for.
+
+
+- 420804d: `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".
+- 716ac9b: fix(driver-sql): one unresolvable WHERE column, one answer — `find()` and `count()` both refuse with `INVALID_FILTER` / 400 naming the column (#8790)
+
+ **BREAKING** accept-set narrowing on a GA public data API, shipped as `minor`
+ under the lockstep launch-window convention. The migration prescription is
+ registered under protocol major 18, where `os migrate meta` users will look.
+
+
+
+ ## The defect
+
+ One predicate had two answers. `SqlDriver.findRows()` carries the #3821
+ unknown-column recovery ladder, and every rung of it is built from
+ `buildBase()`, which **always re-applies `query.where`**. So the ladder can drop
+ a projection and can drop an ORDER BY, but it can never drop the clause that
+ actually failed when the unresolvable column is in the WHERE — both rungs raise
+ the same error and the method fell to `return []`. `SqlDriver.count()` runs a
+ separate statement and has no ladder at all, so the identical predicate threw.
+
+ Measured on a real `SqlDriver` over better-sqlite3, one table, one seeded row:
+
+ ```
+ where { 'title.x': 'y' }
+ find() -> 0 rows, NO ERROR
+ count() -> THREW code=SQLITE_ERROR status=undefined
+ select count(*) as `count` from `task` where `title`.`x` = 'y'
+ - no such column: title.x
+
+ CONTROL where { title: 'Design' }
+ find() -> 1 row
+ count() -> 1
+ ```
+
+ A list view calls both halves, so one query produced an empty page from the rows
+ half and a 500-shaped failure from the total half. A caller reading only the rows
+ got a silent empty page that says "no records exist" for what was really "your
+ predicate never ran" — the single most AI-legible failure to get wrong, since an
+ agent reads "no matching records" and writes its next query on that belief.
+
+ The thrown half was no better: the dialect's own `code`, no `status` (so an
+ unclassified 5xx at the REST boundary rather than a caller mistake), and the
+ statement's **bound literals inlined in the message** — the same predicate-text
+ disclosure shape #7929 redacted elsewhere.
+
+ ## The fix
+
+ Ruled 2026-08-15 on #8790: **refuse both halves** with `INVALID_FILTER` / 400,
+ naming the column. That envelope is not minted here — it is what every sibling
+ refusal on this path already answers, required on both SQL drivers by
+ `cross-field-conformance-cases.ts` and pinned by
+ `sql-driver-boolean-identity.test.ts` and
+ `sql-driver-cross-field-conformance.test.ts`. What closes is a
+ declared-vs-enforced gap, not a new posture.
+
+ The caller-visible message names the column and the object and nothing else. The
+ dialect's own message — the compiled statement, bound literals and all — goes to
+ the **server log** instead, so the operator keeps the debugging aid that
+ `count()`'s raw throw used to provide without it reaching the caller.
+
+ **The #3821 ladder keeps both of its recoveries.** Only the WHERE-failure
+ terminal `return []` became a refusal, and the asymmetry is the ruling rather
+ than an oversight: "rows matter more than their order" is an argument about how
+ rows are *presented*, and it does not transfer to a predicate. A dropped sort is
+ a correct answer in an unhelpful order; a dropped WHERE is records the caller
+ explicitly excluded. Recover-both was rejected for exactly that reason.
+
+ ## Reach, stated rather than assumed
+
+ The refusal fires on the wordings the ladder has always recognised — SQLite
+ (`no such column: x`) and Postgres (`column "x" does not exist`). MySQL spells
+ the condition `Unknown column 'x' in 'where clause'`, which neither arm matches,
+ so on MySQL an unresolvable column still travels out as the raw dialect error.
+ That gap is pinned as a fact in the new suite and filed separately: widening the
+ predicate would also hand MySQL the #3821 projection and ORDER-BY recoveries it
+ has never had, which is an accept-set change in the opposite direction from this
+ one.
+
+ ## Who is affected
+
+ Callers that reach the driver with a filter key the table has no column for. The
+ ingress doors already refuse this where they can judge — `assertFilterFieldsExist`
+ (`@objectstack/metadata-protocol`) answers `INVALID_FIELD` / 400 for everything
+ reaching `findData`, with the sentence this refusal now echoes verbatim: *a
+ filter on a field that does not exist can only match zero records, so the query
+ was refused instead of answered with an empty list*. What changes is the
+ backstop underneath them: a registry the door could not read, and a dotted key
+ judged on its head segment only.
+- 62b1427: 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)
+ ```
+
+
+- 7ea1372: 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' }]
+ ```
+
+
+- 23abe27: 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.
+- a8189ae: feat(objectql,metadata-protocol): refuse a dotted filter key whose head is a relation, a formula, or a plain scalar — at both doors (#8371)
+
+
+
+ **BREAKING** accept-set narrowing on the FILTER axis, landing after the v17.0.0
+ cut (the lockstep launch-window convention ships it as `minor`; the migration
+ prescription is registered under protocol major 18, where `objectstack migrate
+ meta` users will look).
+
+ FILTER was the last of the four query axes with no verdict for a dotted name:
+ SORT refuses it (#4256), PROJECTION refuses it at both doors (#7589), while
+ `where: { 'project_id.name': 'Apollo' }` cleared the unknown-field check on its
+ head segment and answered `200` with zero rows. Measured across all three
+ drivers before ruling (#8371): relation-head, formula-head, system-column-head
+ and plain-scalar-head dotted filters return zero rows on `driver-memory`,
+ `driver-sql` and `driver-mongodb` alike — a lookup stores the related record's
+ scalar id, so there is no working capability for this refusal to remove; every
+ answer was a silent empty list indistinguishable from an empty table, and the
+ virtual case answered one unserviceable intent two ways by spelling
+ (`{is_open: true}` refused since #8296, `{'is_open.x': true}` not).
+
+ **What is refused:** a dotted filter key whose head field is a relation
+ (`lookup`/`master_detail`/`user`/`tree`), a virtual `formula`, or a plain
+ scalar — `400 INVALID_FIELD`, naming the whole offending key, at both the REST
+ ingress (`assertFilterFieldsExist`) and the engine's own filter seam
+ (`assertFilterIsMaterializable`, reached by saved reports, flows and dashboard
+ widgets whose filters never pass the ingress). Both doors judge the head by the
+ shared `@objectstack/spec/data` classification (`classifyDottedFilterHead`,
+ new export), so they cannot drift apart. Precedence mirrors the sort axis:
+ `unknown` > `dotted` > unmaterializable.
+
+ **What stays accepted:** a dotted path into a structured/JSON head
+ (`{'address.city': 'Beijing'}`) — deliberately unjudged per the ruling, since
+ it genuinely works on two of three backends; array-valued and file heads, for
+ the same reason; the nested-relation OBJECT form `{ owner: { region: 'NA' } }`;
+ and every undotted spelling, byte-identically.
+
+ ## FROM → TO
+
+ ```ts
+ // before — 200, zero rows, indistinguishable from an empty table
+ await engine.find('task', { where: { 'project_id.name': 'Apollo' } });
+
+ // after — 400 INVALID_FIELD naming 'project_id.name', with the remedy:
+ // denormalise the value onto a stored field of the queried object and
+ // filter that (or, to test the relation itself, filter the head field):
+ await engine.find('task', { where: { project_id: apolloId } });
+ ```
+
+ There is deliberately no automatic rewrite: the platform cannot invent the
+ stored column the remedy prescribes, and it must not join or post-filter
+ instead — the drivers have already applied `limit`/`offset`, so a post-hoc
+ predicate would filter an arbitrary page.
+- 2b292ce: 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.
+- 8b9eba5: feat(spec): field-level `relatedListFilter` — a declarative default filter for auto-derived related lists (#8704)
+
+
+
+ The field-level related-list family (`relatedList` / `relatedListTitle` /
+ `relatedListColumns`) gains its fourth member, `relatedListFilter` — closing the
+ gap where the only way to filter an auto-derived related list was to abandon the
+ auto-derived record page for a hand-written `record:related_list` page
+ (maintainer ruling 2026-08-15 on #8704).
+
+ - **No new filter dialect**: the key carries the canonical Query-DSL
+ `FilterCondition` (the same authoring face as a query `where`, dataset scope
+ filters, and `summaryOperations.filter`). The FILTER-axis doors therefore
+ apply automatically — the schema door refuses bare date-range preset
+ comparands in ordering positions at parse (#8793), and the engine doors judge
+ the composed query at run time (`formula` keys refused `INVALID_FIELD`,
+ #8296).
+ - **Contract semantics, pinned**: the declared constraint is AND-composed with
+ the parent-relationship condition `{ [referenceField]: parentId }` — an
+ authored constraint, never a user-editable suggestion — and the related-list
+ tab badge count honors the same composed filter, so counts match visible
+ rows. Both clauses are normative in the key's contract text and pinned by
+ tests.
+ - **`@objectstack/lint`**: the shared authored-filter walk (`FILTER_KEYS`) now
+ recognizes `relatedListFilter`, extending the filter-token, empty-combinator
+ and preset-comparand rules to the new position.
+
+ The consumption half (RecordDetailView auto-derivation + tab badge) is
+ objectui#4664, `Blocked-by:` this change; until it lands the key is ledgered
+ `planned` with an author warning.
+- d575779: feat(spec): refuse malformed field `scale`/`precision` declarations at authoring time (#8321)
+
+ **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
+ launch-window convention ships it as `minor`; the migration prescription is
+ registered under protocol major 18, where `os migrate meta` users will look).
+
+ `Field.scale` ("Decimal places") and `Field.precision` ("Total digits") are
+ digit counts, but both parsed as bare `z.number()` — admitting `scale: 2.5`
+ and `scale: -1`, neither of which has a defined meaning as a count. That
+ looseness became load-bearing when #7501 made `scale` enforced at write time:
+ the runtime branch deliberately guards on `Number.isInteger(def.scale) &&
+ def.scale >= 0` (inventing floor/round semantics in a consumer would be PD #12
+ guessing), so a typo'd declaration silently got **no enforcement at all** —
+ the declared-but-inert shape that hides AI-authored metadata errors.
+
+ **What is refused:** a non-integer or negative `scale` or `precision`, at
+ parse time with the issue path and substance (`invalid_type` "expected int" /
+ `too_small` ">=0") — the house `z.number().int().min(0)` shape (ADR-0078
+ declared=enforced).
+
+ **What stays accepted:** every well-formed declaration byte-identically
+ (`0`, `2`, any non-negative integer, or no declaration).
+ `CurrencyConfigSchema.precision` (under `currencyConfig`) is a **different
+ surface** with its own bounds and `scale → precision` alias table — unchanged.
+
+ **Stored metadata is not hard-broken:** a `sys_metadata` row already at rest
+ with a malformed value keeps loading — the ADR-0087 D2 conversion
+ `field-malformed-scale-precision-removed` (retired from the load path,
+ replayed by the stored-row rehydration seam and `os migrate meta`) drops the
+ meaningless key, which is behaviour-preserving because a malformed declaration
+ enforced nothing. The semantic entry
+ `field-scale-precision-integer-refused` (protocol major 18) tells authors to
+ re-declare the digit count they meant.
+
+
+- c5ac5e4: feat(spec): `placeholder` becomes a declared `FieldSchema` key — the producer moves to meet four shipped objectui render surfaces (#9019, maintainer Option C ruling on objectui#4676)
+
+
+
+ `FieldSchema` refused `placeholder` by name ("never a FieldSchema key. Author
+ hint text through `inlineHelpText` or `description`.") while four objectui
+ packages plus `apps/console` — plugin-form's auto-generated and sectioned
+ forms, plugin-detail's inline edit, app-shell's field-backed action params
+ (whose module header documents the inheritance as intended), and console's
+ FormPage — apply an object-field-level `placeholder` at render time, feeding
+ the `@object-ui/fields` widgets. That was the preview-renders/save-422s trap:
+ the designer preview rendered the key, `PUT /api/v1/meta/object/:name`
+ refused it.
+
+ Per the 2026-08-16 maintainer ruling (Option C on objectui#4676, measured in
+ its report comment 5301288148):
+
+ - `placeholder` is now a declared optional string key on `FieldSchema`, with
+ the semantics the renderers already implement: in-input placeholder text
+ (the HTML `placeholder` attribute), distinct from `inlineHelpText`
+ (always-visible help beside/under the input) and `description` (tooltip).
+ - The `FIELD_KEY_GUIDANCE` retirement entry steering authors away from the key
+ is removed — after this change that prose would contradict the contract.
+ - The Studio metadata forms (`object.form.ts` quick-add grid, `field.form.ts`
+ full editor) offer the key, and the liveness ledger carries a `live` verdict
+ with the measured cross-repo evidence.
+
+ The matching translation surface (`FieldTranslation.placeholder`) was already
+ declared, so a translated placeholder now has a declared base key to land on.
+- a777944: feat(spec,lint): refuse a bare date-range preset name in an ordering filter comparand at publish time (#8793 — the ruled C half of #8690)
+
+ **BREAKING** accept-set narrowing on a published authoring surface, landing
+ after the v17.0.0 cut (the lockstep launch-window convention ships it as
+ `minor`; the migration prescription is registered under protocol major 18).
+
+ `last_7_days` / `last_30_days` / `last_90_days` and their ten calendar
+ siblings are real, declared preset names — for the dashboard date-filter
+ positions, where the console lowers them to `{date-macro}` bounds before any
+ query is sent. Authored as a bare filter comparand nothing resolves them:
+ measured on #8690, `$gte "last_30_days"` returned HTTP 200 with 0 of 51 rows
+ where `$gte "{30_days_ago}"` returned the 38 in-window. The engine now
+ refuses the bare name on a declared temporal field at query time
+ (`INVALID_FILTER` / 400, PR #8808 — the B half); this change is the
+ authoring-time half the same ruling shipped alongside it.
+
+ **What is refused — ordering positions only, in all three authored filter
+ shapes:** a `$gt` / `$gte` / `$lt` / `$lte` comparand or `$between` endpoint
+ on every carrier of `FilterConditionSchema` (dashboard widget filter, dataset
+ filter, report `runtimeFilter`, page/component filter, rollup filter), a
+ `greater_than` / `less_than` / `before` / `after` / `between` view filter
+ rule value, and an ordering `[field, op, value]` filter triple (the latter
+ two via `@objectstack/lint`'s new gating rule `filter-preset-comparand`,
+ which also runs at the runtime publish gate for `dashboard` / `view` /
+ `object` / `page` / `flow` writes). The refusal names the offending value,
+ the position, and the exact `{date-macro}` window that works.
+
+ **What stays accepted:** the preset names in the dashboard date-filter
+ positions (`dateRange.defaultRange`, a date global filter's `defaultValue`) —
+ the only positions any layer ever resolved them; equality and membership
+ comparands (`{ period: 'this_quarter' }`, `$in: [...]`) — a select/picklist
+ column legitimately stores colliding values, and the engine's field-typed
+ door already covers the temporal case; undeclared strings
+ (`'not-a-date-at-all'`) — the field-typed engine door owns those; and the
+ empty-string cell, which stays its own card by ruling.
+
+ ## FROM → TO
+
+ ```ts
+ // before — parsed green, returned a silent zero (or 400 at query time since #8808)
+ filter: { closed_at: { $gte: 'last_30_days' } }
+
+ // after — rejected naming the window; write the date-macro spelling
+ filter: { closed_at: { $gte: '{30_days_ago}' } }
+ // calendar presets prescribe their pair:
+ filter: { closed_at: { $between: ['{week_start}', '{week_end}'] } }
+ ```
+
+ `DATE_RANGE_PRESETS` moved to `@objectstack/spec/data`
+ (`data/date-range-presets.ts`) with `ui` re-exporting it, so both import
+ paths keep working; `DATE_RANGE_PRESET_MACRO_WINDOWS` (the per-preset macro
+ window table the refusals quote) and `isDateRangePresetName` are new exports.
+
+
+- 870f710: 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.
+- 7ff3975: 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.
+- 65589d6: feat(spec): `icontains` joins the view and infix filter vocabularies, closing the dialect gap on the capability every driver executes (#8934)
+
+ `$icontains` has been executable on every driver and evaluation face since
+ #5702/#6520, yet it was authorable from exactly one of the three filter
+ dialects — the MongoDB-style `FieldOperatorsSchema`. Maintainer ruling
+ (Option A on #8934): the two remaining vocabularies gain the canonical
+ spelling.
+
+ - `VIEW_FILTER_OPERATORS` (`ui/view.zod.ts`) gains `icontains`, so a
+ `ViewFilterRule` can declare a case-insensitive contains. No alias rows:
+ the alias table bridges spellings already living in stored metadata, and a
+ new canonical operator has none.
+ - `AST_OPERATOR_MAP` (`data/filter.zod.ts`) gains `icontains` → `$icontains`,
+ so `isFilterAST` accepts the infix spelling and `parseFilterAST` lowers it
+ to the operator the drivers already run. `canonicalAstOperator` round-trips
+ it through the generic path (`CANONICAL_INFIX` row added).
+ - Boundary preserved, per the ruling: `icontains`/`$icontains` (LIKE-escaped
+ substring — a comparand `%` is a LITERAL) and `ilike`/`$ilike` (raw LIKE
+ pattern) are NOT aliases of each other in either vocabulary, and there is no
+ `not_icontains` — the `$` dialect has no `$notIcontains`, and the authoring
+ vocabularies mirror the executed set rather than widening it.
+ - The parity suite (`filter-view-operator-parity.test.ts`) and
+ `FILTER_TEXT_CASES` extend accordingly, including a conformance case that
+ lowers the infix spelling and pins `%`-literalness on every backend that
+ runs the table. The comparand-type door already judged `$icontains`
+ (a `FieldOperatorsSchema` key since #5701) — no change needed there.
+- 2c86fe3: feat(spec): retire `ApiKeySchema` — the identity module no longer publishes a second, fictional declaration of `sys_api_key` (#8715, ADR-0049)
+
+
+
+ **BREAKING** public-surface removal, landing after the v17.0.0 cut (the
+ lockstep launch-window convention ships it as `minor`; the migration
+ prescription is registered under protocol major 18, where `os migrate meta`
+ users will look — the #8586 precedent).
+
+ `ApiKeySchema` (and its `ApiKey` / `ApiKeyParsed` types) documented
+ better-auth's `apiKey` **plugin** schema — a plugin this platform does not
+ load: `start` and `lastRefetchAt` name columns that do not exist; `enabled`
+ inverts the real `revoked` column's polarity; `rateLimitEnabled` /
+ `rateLimitTimeWindow` / `rateLimitMax` / `remaining` advertise a per-key
+ rate-limit capability nothing implements; `permissions` and `metadata` have no
+ columns; `organizationId` is camelCase fiction next to the real snake_case
+ `active_organization_id`. Zero consumers anywhere in the monorepo outside its
+ own unit test — one table had two declarations, and the published one was
+ fiction (maintainer-ruled DELETE, 2026-08-15).
+
+ **What breaks:** `import { ApiKeySchema, ApiKey, ApiKeyParsed }` from
+ `@objectstack/spec` or `@objectstack/spec/identity` is TS2305 after upgrade.
+ The generated reference page's `ApiKey` section and the 19
+ `identity/ApiKey:*` authorable-surface keys disappear with the schema.
+
+ **What stays:** everything real. The single declaration of `sys_api_key` is
+ the ObjectSchema in `@objectstack/platform-objects`
+ (`identity/sys-api-key.object.ts`) — columns `name, prefix, user_id,
+ active_organization_id, scopes, expires_at, last_used_at, revoked, key, id,
+ created_at, updated_at`; rows are minted by `POST /api/v1/keys` and verified
+ by `core/src/security/api-key.ts`, keyed by the `osk_` prefix. Neither ever
+ read the deleted schema, so runtime behaviour is byte-identical.
+ `UserSchema` / `AccountSchema` / `VerificationTokenSchema` and the
+ organization module survive unchanged.
+
+ The retirement kit:
+
+ - schema deleted in place, with the in-module explanatory block naming the
+ live declaration (`packages/spec/src/identity/identity.zod.ts`)
+ - ADR-0087 registration: retired-def entry `identity/ApiKey` + D3 semantic
+ entry `identity-api-key-schema-retired`, both under protocol 18 (route 3 —
+ no carrier key and no authored document, so no tombstone and no D2
+ conversion; the registry entries ARE the declaration)
+ - pin tests: `identity/api-key-retirement.test.ts` (zero holders on every
+ public entry, survivors stand) and platform-objects'
+ `sys-api-key-single-declaration.test.ts` (the real column set, spec's
+ runtime namespace lost the name)
+ - generated baselines regenerated: authorable surface (−19 keys), JSON-schema
+ manifest (−1 def), api-surface / export-origins (−3 names), reference docs
+ - `cloud/developer-portal.zod.ts` prose corrected: marketplace API keys point
+ at the `sys_api_key` object and `POST /api/v1/keys`, not at
+ `Identity.ApiKeySchema` (the marketplace-key plan is ruled not live)
+
+ ## FROM → TO
+
+ ```ts
+ // before — type-checked green against a schema no runtime ever read
+ import { ApiKeySchema, type ApiKey } from '@objectstack/spec/identity';
+ const key: ApiKey = { id, name, userId, enabled: true, rateLimitMax: 100, /* … */ };
+
+ // after — read the real table: the sys_api_key ObjectSchema in
+ // @objectstack/platform-objects (snake_case, `revoked` not `enabled`);
+ // mint via POST /api/v1/keys, verify via core/src/security/api-key.ts.
+ import { SysApiKey } from '@objectstack/platform-objects';
+ ```
+- 19539b4: feat(spec): strict element schemas for `Field.inlineColumns` and `Field.relatedListColumns` (#9227)
+
+ **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 #9221/#9250 precedent).
+
+ Both keys were `z.array(z.any())`: every column object validated — right keys,
+ wrong keys, misspelled keys, empty objects — so a mis-keyed column published
+ clean and surfaced only in the browser, as a grid with the right row count and
+ every cell blank (the objectui#3951 failure, reachable from the authoring side).
+
+ - `inlineColumns` entries are now `InlineGridColumnSchema` (exported): a
+ strict, `name`-keyed column mirroring the objectui inline-grid renderer's
+ measured reads — `name` (required), `label?`, `type?`, `width?`, `required?`,
+ `options?`, `prefix?`, `step?`, `reference?`, `displayField?`, `idField?`,
+ `multiple?`, `accept?`, `defaultHidden?`, `computed?`, `expr?`, `scale?`,
+ `autofill?`, `readonlyWhen?`, `requiredWhen?`. Unknown keys are a named
+ rejection at publish time; the retired `field` spelling is refused with the
+ prescription naming `name` (objectui#3951 aligned the widget to `name` with
+ deliberately no tolerant alias). `expr` is the grid evaluator's BARE
+ arithmetic string — a CEL envelope there is refused. Identity-only entries
+ (`{ name: 'quantity' }`) remain the recommended form: objectui's
+ `hydrateColumns` fills everything else from the child object's fields.
+ - `relatedListColumns` entries are now child FIELD-NAME STRINGS (e.g.
+ `['name', 'status']`) — the only authored form in-repo and the only form the
+ related-list renderer hydrates fully (labels, cell types and formatting
+ derive from the child object's field definitions); the page-block sibling
+ `record:related_list.columns` is the same strings-only shape. A column
+ object is refused with a prescription pointing at the child fields.
+
+ Migration: respell `{ field: 'x' }` inline-grid columns as `{ name: 'x' }`;
+ replace related-list column objects with the child field name string — or run
+ `os migrate meta`, which rewrites both mechanically (registered conversion
+ `field-column-lists-canonicalized`, protocol 18). The one in-repo usage
+ (`examples/app-showcase` invoice line items) is migrated in this change.
+
+
+- 11b779e: Declare `MetadataProtocol.getMetaItemLayered` — the layered three-way diagnostic read (`GET /api/v1/meta/:type/:name/layers`) now appears on the protocol interface, typed against the already-declared `GetMetaItemLayeredRequestSchema` / `GetMetaItemLayeredResponseSchema`, so callers no longer reach the verb through `any`. Declared optional like its `getMetaItemCached` / `deleteMetaItem` siblings: a declared-surface catch-up to a shipped verb, not a new capability.
+
+ In `@objectstack/metadata-protocol`, the implementation's inline return-type annotation for `getMetaItemLayered` drops its dead `'overlay'` arm on `lockSource` and annotates with `MetadataLockSource` directly — the only producer feeding that field on the layered read path is `resolveLockState`, whose return is already typed `MetadataLockSource | undefined` (`'artifact' | 'package' | 'env-forced'`); the `'overlay'` literal in the file belongs to `getEffectiveLock`, a write/delete-door helper that never feeds this response. Type-level change only; no runtime behaviour or wire vocabulary changes.
+- 4bfe1a5: feat(spec): refuse `${…}` placeholder syntax in memory `persistence.path` / `persistence.key` at publish (#8495)
+
+ **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
+ launch-window convention ships it as `minor`; the migration prescription is
+ registered under protocol major 18, where `os migrate meta` users will look).
+
+ The #8336 defect one surface over: a `${…}` placeholder written in the memory
+ driver's persistence config (e.g. `persistence: { type: 'file', path:
+ '${DATA_DIR}/mem.json' }`) is resolved by **nothing** — the driver would create
+ and write a literal `./${DATA_DIR}/…` path, or write under the literal
+ placeholder-bearing localStorage key, with no error naming the unresolved
+ placeholder. #8336's ruling (refuse loudly at authoring time — the value was
+ authored under a false belief) applies to these two keys with its reason
+ intact: they are config-material like the connection keys, not record data.
+
+ **What is refused:** a complete `${…}` span in memory `persistence.path` (file
+ persistence and the `auto` override) or `persistence.key` (localStorage and the
+ `auto` override) — the same shared judgment (`placeholderFree`) the
+ connection-material keys use, so the policy cannot drift per key.
+
+ **What stays accepted:** every literal path/key byte-identically, including
+ placeholder-looking near-misses (`$VAR`, `{name}`, an unclosed `${`) — and the
+ memory driver's `initialData` stays deliberately **unjudged**: it carries
+ arbitrary record values, where a literal `${…}` may be legitimate data (the
+ mother ruling's deliberate memory-driver exclusion, which reached exactly as
+ far as its reason did).
+
+ ## FROM → TO
+
+ ```ts
+ // before — parsed green; the driver created a literal `./${DATA_DIR}/…` path
+ defineDatasource({
+ name: 'scratch', driver: 'memory',
+ config: { persistence: { type: 'file', path: '${DATA_DIR}/scratch.json' } },
+ })
+
+ // after — write the literal path (or leave it unset: the shared datasource
+ // factory scopes the default destination per datasource)
+ defineDatasource({
+ name: 'scratch', driver: 'memory',
+ config: { persistence: { type: 'file', path: './data/scratch.json' } },
+ })
+ ```
+
+ There is deliberately **no automatic rewrite**: the placeholder names a value
+ that exists only in the author's intended deployment environment, which a
+ source-file transform cannot know. `os migrate meta` surfaces the change as a
+ structured TODO (semantic entry `memory-persistence-placeholder-refused`,
+ protocol major 18 — this refusal is not part of the v17.0.0 cut).
+
+
+- 2065e31: Declare `organizationId` on the metadata read request schemas — `GetMetaItemsRequestSchema`, `GetMetaItemRequestSchema` and `GetMetaItemCachedRequestSchema` — matching the member the protocol implementation has accepted and honoured all along (it selects the org partition in the ADR-0005 overlay read order, deciding which tenant's customization rows are served; on the cached read it also enters the ETag). Also declares `GetMetaItemLayeredRequestSchema` (+ `GetMetaItemLayeredRequest`), the request shape of `GET /api/v1/meta/:type/:name/layers`, mirroring the implementation's parameter type member for member alongside the already-declared layered response schema. Accept-set widening catch-up only: no runtime behaviour changes, and requests without `organizationId` remain valid environment-wide reads.
+- b69d0f5: fix(metadata): `PUT /meta/:type` refuses a type name the platform does not have, instead of minting a namespace for it (#8421)
+
+
+
+
+ **BREAKING** accept-set narrowing on a published HTTP surface, landing after the
+ v17.0.0 cut (the lockstep launch-window convention ships it as `minor`). A write
+ that answered `200 {"success":true}` now answers `400 INVALID_REQUEST`:
+
+ ```
+ PUT /api/v1/meta/fieldz/showcase_task.title
+ before → 200, sys_metadata row persisted with type='fieldz'
+ after → 400 INVALID_REQUEST, nothing persisted
+ ```
+
+ `fieldz` — or any typo — was neither a declared metadata type nor a known plural
+ spelling of one, so the boundary classified it as PLUGIN-registered, which every
+ authorization gate is permissive toward by construction. The row was persisted
+ under a type nothing reads and nothing serves, and the caller was told it had
+ succeeded. That silence is the real cost: a metadata-type typo, from a human or
+ from generated code, produced `success: true` and no indication the type is not
+ real.
+
+ **Why this is only now safe to refuse.** #7894 closed the sibling case (a plural
+ spelling of a type the platform DECLARES) and left this one open on purpose: a
+ static predicate cannot tell `fieldz` from a plugin kind, and the live-registry
+ alternative was measured to be worse than the defect — the live type set is
+ ITEM-POPULATED, so it omits every legitimate kind that has no items yet, which
+ is the state each kind is in immediately before its first create. What changed
+ is the platform, not the boundary's information: #8586 retired
+ `MetadataPluginConfig.additionalTypes` and with it the last channel by which a
+ plugin could DECLARE a metadata kind, so an unrecognised name can no longer be a
+ declaration this refusal has not heard about (maintainer ruling 2026-08-14).
+
+ **What still passes, pinned in both directions.** Every declared type in
+ `DEFAULT_METADATA_TYPE_REGISTRY`, in canonical and REST-plural spelling; every
+ manifest spelling and the singular each folds to; and the six plugin kinds that
+ have no static registry entry at all — `theme`, `webhook`, `connector`,
+ `sharing_rule`, `analytics_cube`, `rag_pipeline`. `PUT /meta/theme/dark` on a
+ deployment with zero themes is explicitly covered, because that first create is
+ exactly what a live-registry check would have broken.
+
+ **The refusal is scoped to the door that mints.** Reads still ANSWER: a running
+ kernel legitimately holds live type keys the static contract does not — `data`,
+ `kind` and `package` all enter the registry during an ordinary `registerApp`,
+ and `GET /api/v1/meta/types` lists that live set — so refusing unrecognised
+ names on the read path would answer 400 for types the same service advertises.
+ `DELETE` is untouched for the mirror-image reason: rows minted under an
+ unrecognised type before this change are real, nothing rewrites them on upgrade,
+ and refusing their deletion would turn the accumulation this fixes into an
+ accumulation nobody can clear.
+
+ **…but one published ADVERTISEMENT narrows with it, and that is a second
+ behaviour change worth reading on its own.** `GET /api/v1/meta/types` keeps
+ listing every live type, and every entry keeps every field — what changes is the
+ VALUE of one boolean:
+
+ ```
+ GET /api/v1/meta/types → entries[] where type ∈ {policy, data, package, kind}
+ before → allowRuntimeCreate: true
+ after → allowRuntimeCreate: false
+ ```
+
+ The listing synthesised `allowRuntimeCreate: true` for every live type with no
+ static registry entry, on the same expired premise as the write door: a name the
+ registry does not carry might be a kind some plugin declared. It now derives that
+ flag from the SAME predicate the mint door enforces, so the two endpoints agree
+ by construction instead of via two rules maintained apart. Nothing ever honoured
+ a runtime create on those four — they are internal bookkeeping (seed datasets,
+ package rows, kind descriptors) — so the advertisement was a promise the platform
+ did not keep, which is the same defect this card is about, relocated to the read
+ door. Direct precedent: `api` declared `allowRuntimeCreate: true`, the runtime
+ never honoured it, and the 2026-08-07 ruling removed the declaration rather than
+ converging the read path onto it.
+
+ ⛔ The six plugin kinds with no registry entry — `theme`, `webhook`, `connector`,
+ `sharing_rule`, `analytics_cube`, `rag_pipeline` — are **not** affected: they are
+ in the static spelling contract, stay advertised `allowRuntimeCreate: true`, and
+ stay mintable. A UI reading this field (Setup → Metadata, the Studio designers)
+ therefore loses create affordances on exactly the four types whose creates were
+ already refused, and keeps them everywhere else.
+
+ **The premise behind both halves is a CURRENT posture, not a closed door.**
+ Maintainer ruling, 2026-08-15, verbatim and untranslated:
+ 暂时不考虑让插件申明新的元数据类型 — plugins do not declare new metadata types
+ *for now*. That word is recorded deliberately: plugin-declared kinds were
+ considered and deferred, not ruled out. If they are ever wanted, the two sites
+ that encode the deferral name it and its date in place —
+ `getMetaTypes()`'s synthesis and `isRuntimeCreateAllowed` in
+ `@objectstack/metadata-protocol` — so the decision is findable rather than
+ re-derived from the code's silence.
+
+ **Two shapes reaching the mint door are exempt, and each is a fact about the
+ request rather than a claim the caller makes.**
+
+ 1. *The COMPOUND arity carries an OBJECT name in the `:type` segment.*
+ `PUT /api/v1/meta/lead/views/all_leads` is `type='lead'`,
+ `name='views/all_leads'` — one operation reaching one save, the shape both
+ the runtime dispatcher and the REST route document verbatim. `lead` is an
+ object, i.e. runtime data no static contract can enumerate, so a type verdict
+ applied there would refuse every object name that is not coincidentally a
+ metadata type. The ruling is about metadata TYPE names like `fieldz`.
+ ⚠️ Residue, stated rather than hidden: `PUT /meta/fieldz/a/b` is therefore
+ still accepted, because at that arity `fieldz` is a claim about an object and
+ the only way to check it is the live-registry lookup this card ruled out.
+ 2. *A namespace that already exists is not being minted.* `duplicatePackage`
+ re-saves every row of a package under a new name, taking each type from the
+ stored row — measured: a package holding one pre-existing residue row
+ answered `{success: false, copiedCount: 0, failedCount: 1}`, i.e. could not
+ be duplicated at all. That contradicts the `DELETE` reasoning above, so the
+ store (never the request) exempts a type that already has rows. The probe
+ runs only once the refusal has already fired, and a store that cannot answer
+ refuses — a fresh deployment has no residue to protect.
+ `migrate meta --stored` was read as a third victim and measured NOT to be
+ one: an unrecognised type has no manifest collection, hence no ADR-0087
+ chain, hence no notice, so such a row is reported `canonical` and the mint
+ door is never reached.
+
+ **What breaks.** A caller creating metadata at runtime, at the simple arity,
+ under a type name that is in neither half of the static spelling contract and
+ has no rows already. That set is **not** empty in this repo — measured on
+ `objectql`, `runtime` and `rest`, three in-tree fixtures minted `trigger` (a kind
+ ADR-0088 retired outright), `policy`, and a synthetic `my_plugin_kind`. All three
+ are corrected here rather than exempted, and each for its own reason: the
+ `trigger` specimens were debt independent of any ruling (a retired kind cannot
+ demonstrate a live tier, and they were green only through the hole this card
+ closes), `policy` becomes a refusal case of its own, and #7894's control keeps
+ its `metaUrlSpellingRefusal` claim while its boundary expectation follows the
+ narrowing. An out-of-tree plugin that made its kind live by registering an item
+ of it, and then accepted runtime writes to that kind through `/meta`, needs its
+ spelling in the contract; there is no declared-kind channel to register one
+ through today — that is the trade #8586's retirement made, and the `暂时` above
+ is what makes it revisitable.
+
+ `@objectstack/spec` gains one export, `unrecognisedMetaTypeRefusal`, alongside
+ the #7894 verdict it deliberately does not merge with: one says *you spelled a
+ declared type wrongly* and can name the replacement, the other says *there is no
+ such type* and never guesses. The residue pin #7894 left behind
+ (`metadata-url-spelling.test.ts`, the case that asserted `fieldz` was refused by
+ nobody) is **flipped, not deleted**. ⚠️ #7894's positive control keeps its own
+ claim intact — `metaUrlSpellingRefusal` still cannot refuse a kind that is a
+ misspelling of nothing, which is what makes that control true by construction —
+ but the BOUNDARY it drives now refuses six of the twelve names it exercises,
+ and that case says so in place rather than leaving it to inference.
+- 4d47afe: feat(spec): retire the inert `additionalTypes` key from `MetadataPluginConfig` (#8586, ADR-0049)
+
+ **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
+ launch-window convention ships it as `minor`; the migration prescription is
+ registered under protocol major 18, where `os migrate meta` users will look).
+
+ `MetadataPluginConfig.additionalTypes` was declared, authorable, and documented
+ on four docs pages as THE way a plugin registers a custom metadata type — and
+ read by **nothing**. The only production writer of the manager's type registry
+ is `setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)`, called exactly once, and
+ it replaces the array outright: measured on the real `MetadataManager`,
+ declared count == live count (27 == 27). An author who followed the published
+ instructions wrote the key, got no error, and nothing happened — the #4212
+ `onInstall` silence trap one level down (maintainer-ruled REMOVE, 2026-08-14).
+
+ **What is refused:** an authored `additionalTypes` on `MetadataPluginConfig`
+ (inline or via the manifest's `config` embed). The key is a `retiredKey()`
+ tombstone — the schema is not `.strict()`, so a plain deletion would have
+ silently stripped it — refused at `tsc` (typed `never`) and at the parse
+ (`invalid_type` at path `additionalTypes`, message carrying the prescription).
+
+ **What stays accepted:** every `MetadataPluginConfig` without the key,
+ byte-identically. Runtime behaviour is unchanged: nothing ever read the key,
+ so removing it removes no behaviour.
+
+ The retirement kit:
+
+ - tombstone at the schema (`packages/spec/src/kernel/metadata-plugin.zod.ts`)
+ - ADR-0087 registration: retired-key entry
+ `kernel/MetadataPluginConfig:additionalTypes` + D3 semantic entry
+ `metadata-plugin-additional-types-retired`, both under protocol 18 (no D2
+ conversion — a plugin config is not a stack collection member, the
+ `kernel/Manifest:loading` precedent)
+ - pin tests (`additional-types-retirement.test.ts`)
+ - docs corrected: `content/docs/plugins/adding-a-metadata-type.mdx` (four
+ sites) now describes how a kind actually enters the live set — as a side
+ effect of registering an item of that kind; the generated reference page
+ follows the schema
+ - the two source comments that asserted the phantom growth path
+ (`metadata-manager.ts`, `metadata-protocol/src/protocol.ts`) and the
+ `registerMetadataTypeSchema` doc note corrected
+
+ ## FROM → TO
+
+ ```ts
+ // before — parsed green; the entries were merged into nothing
+ const config: MetadataPluginConfig = {
+ storage: {},
+ additionalTypes: [{ type: 'chart', label: 'Chart', filePatterns: ['**/*.chart.ts'], domain: 'ui' }],
+ };
+
+ // after — delete the key; register items of the kind instead, and bind its schema
+ const config: MetadataPluginConfig = { storage: {} };
+ // in the plugin: registerMetadataTypeSchema('chart', ChartSchema) from init(ctx);
+ // the kind enters the live set when an item of it is registered.
+ ```
+
+
+- d31785f: feat(automation): flow `notify` nodes can reference an email template for localized delivery — `template` + `templateData` on `NotifyNodeConfig`, resolved by `(name, recipient locale)` at delivery time (#9205)
+
+ Ruled 「立项,走 emailTemplates 路线」: instead of widening the `flows`
+ translation surface (whose guidance excludes notification text, #7646), a
+ `notify` node now bridges to the existing localized email-template subsystem.
+
+ - **Spec** — `NotifyConfigSchema` gains `template` (a `sys_email_template`
+ name, read raw like `topic`/`channels`) and `templateData` (render context
+ for the template's `{{var}}` holes; values interpolate `{token}` templates
+ per run) as the localizable alternative to inline `title`/`message`. Inline
+ strings stay fully valid and byte-identical for existing flows — they are
+ the non-localizable path, and the describes now say so. A node carrying BOTH
+ paths, or `templateData` without `template`, or NEITHER path, is refused
+ loudly with the fix in the message (the `objectNavTargetExclusivity`
+ posture: unrepresentable over silent precedence).
+ - **service-automation** — the notify executor forwards the template
+ reference and its interpolated render context in the emit payload (the
+ outbox snapshots it onto each delivery row), and no longer demands an
+ inline title when a template is referenced.
+ - **service-messaging** — the email channel routes a template-carrying
+ delivery through `IEmailService.sendTemplate({ template, locale, data })`,
+ resolving the recipient locale per delivery: `payload.locale` if the
+ producer set one, else the deployment default
+ (`II18nService.getDefaultLocale()`, the #8195 ruled source), else
+ `sendTemplate`'s documented `en-US` ladder. Template-resolution failures
+ (`TEMPLATE_NOT_FOUND` / `TEMPLATE_INACTIVE` / `MISSING_VARIABLES`, and an
+ email service without `sendTemplate`) are graded `permanent` — dead
+ immediately with the code on the delivery row, instead of burning the retry
+ schedule on metadata that cannot fix itself.
+
+ The inbox channel keeps its existing rendering (notification title/body,
+ falling back to the topic on the template path): it has no locale-capable
+ rendering seam to the email-template subsystem today, and that gap is
+ documented in the PR rather than papered over with a duplicated resolver.
+- c308a4f: feat(spec): refuse undeclared keys on object `indexes[]` entries (#4001 批 20 site 14, the held `IndexSchema`)
+
+ **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
+ launch-window convention ships it as `minor`; the migration prescription is
+ registered under protocol major 18, where `os migrate meta` users will look).
+
+ `IndexSchema` — 批 20's one deliberately-held site — is now `strictObject` like
+ its thirteen siblings. The hold was a measured #5114-class risk, not an
+ unfinished to-do: objectui's embedded index editor shipped a drifted
+ hand-copied schema (`FALLBACK_SCHEMAS.index`) offering `where` for a
+ partial-index predicate and `brin` in an algorithm enum, spliced its form
+ output into `object.indexes[]` and PUT the whole object — so closing the shape
+ would have 422'd a control the console itself rendered. objectui#4772
+ converged that editor to the declared surface (`name` / `fields` / `unique`),
+ spending the hold's evidence.
+
+ Before this change an undeclared key on an index parsed clean and was silently
+ dropped: an admin filling the old "Partial-index predicate" control got a
+ green save while no driver ever read the predicate
+ (`SqlDriver.syncDeclaredIndexes` consumes `name`/`fields`/`unique` only).
+
+ **What is refused:** any key the shape does not declare, with a prescriptive
+ message naming the surface and the offending key. `where` carries a curated
+ guidance entry — the predicate belongs at the database layer
+ (`CREATE [UNIQUE] INDEX … WHERE` from a runtime migration, the
+ `ensureOverlayIndex` pattern), deliberately NOT a rename onto the retired
+ `partial` tombstone (a suggestion pointing into a second rejection).
+
+ **What stays accepted:** every declared key byte-identically, including every
+ ADR-0120 `unique` scope spelling — and the protocol-17 `type`/`partial`
+ tombstones keep answering their own migration prescription rather than
+ degrading to a generic `unrecognized_keys`.
+
+ ## FROM → TO
+
+ ```ts
+ // before — parsed green; the predicate was silently dropped, the index built FULL
+ indexes: [{ fields: ['status'], where: "status = 'open'" }]
+
+ // after — rejected with the database-layer prescription; declare only what is materialized
+ indexes: [{ fields: ['status'] }]
+ // …and issue `CREATE INDEX … WHERE ` from a runtime migration when
+ // a partial index is actually needed.
+ ```
+
+ There is deliberately no automatic rewrite: an undeclared key here either
+ names a capability the declaration surface does not deliver (blessing it would
+ be declared-but-unenforced surface, ADR-0078) or is a spelling of a declared
+ one, which the rejection names. `os migrate meta` surfaces the change as a
+ structured TODO (semantic entry `object-index-unknown-keys-refused`, protocol
+ major 18 — this refusal is not part of the v17.0.0 cut).
+
+
+- e2899f6: Declare 14 registry-published props on the react-tier `ObjectForm` block (ADR-0082 D4 declaration parity, #9392): `modalCloseButton`, `contentLayout`, `confirmOnDiscard`, `customFields`, `readOnly`, `submitText`, `cancelText`, `nextText`, `prevText`, `showSubmit`, `showCancel`, `showReset`, `successMessage`, `resetOnSuccess` — the inputs objectui#4648/objectui#4901 published on the `object-form` registration that the react-blocks channel of the spec never declared. Descriptions are adapted from objectui's own registration; the generated react-blocks contract (`skills/objectstack-ui`) picks them up.
+
+ Three registry inputs are deliberately NOT declared and are instead baselined with recorded reasons (maintainer ruling 2026-08-18 on #9392): `initialData` (alias spelling of `initialValues` — aliases are not promoted into spec), `mobile` (internal presentation override, not an authoring surface), and `navigateOnSuccess` (parked pending the action-success-navigation family ruling; revisit tracked on #9392).
+- 3851f87: Partial field masking (#8993): `FieldSchema` declares `maskingRule` — a closed
+ preset enum (`phone`, `id_card`, `bank_account`, `email`, `name`) plus a
+ `{ keepHead, keepTail }` escape hatch — and plugin-security's `FieldMasker`
+ enforces it in the same PR (ADR-0049 declare = enforce; the key re-enters the
+ schema only with its runtime consumer attached, honouring the 2026-06 prune in
+ spirit).
+
+ A field declaring a rule is served masked-but-recognisable (`138****5678`) to
+ every non-system caller; the field's `requiredPermissions` (ADR-0066 D3) is the
+ unmask gate — holders of all listed capabilities read the full value. A
+ permission set that marks the field non-readable still deletes it entirely.
+ Masking rides the single runtime channel, so API callers, browser users, the
+ CSV/XLSX export route and the AI-context interceptor all see the same
+ deterministic, length-preserving masked value. Masked callers cannot filter,
+ sort, group or aggregate on the field (403, the FLS predicate-oracle guard),
+ and a write that round-trips a masked placeholder is refused with
+ `400 VALIDATION_ERROR` instead of silently overwriting the stored value.
+ New exports: `FieldMaskingRuleSchema`, `FieldMaskingKeepSchema`,
+ `FIELD_MASKING_PRESETS`, `maskFieldValue`, `MASK_CHAR`.
+- 2a29caa: Declare the draft-visibility switches on the meta-read request schemas, exactly where the implementation enforces them (#9741, maintainer ruling 2026-08-18): `GetMetaItemsRequestSchema` gains `previewDrafts?: boolean`, and `GetMetaItemRequestSchema` gains `state?: 'active' | 'draft'` plus `previewDrafts?: boolean`. Both members are draft-visibility switches only — declaration ≠ authorization: ADR-0106 masking is unaffected, and draft access stays admin-gated upstream. The cached and layered read requests deliberately declare neither (their implementations enforce neither). `environmentId` stays OUT of the protocol request shape by explicit ruling — it is the transport-level multi-kernel routing key, recorded schema-side as a decision rather than an omission. The REST meta-read doors (list, cached and uncached single-item, layered) drop their `as any` request casts: each request literal now compiles against the declared spec shape, with the transport-level `environmentId` carried by a typed transport envelope (`TransportScopedMetaRequest`) instead of a cast. Accept-set widening catch-up on the declared surface; zero runtime behaviour change.
+- 09a6eee: The publish door now reports the runtime authoring gate's advisory findings (#9176). `POST /api/v1/meta/:type/:name/publish` carries the same optional, omitted-when-empty `advisories` key the save door already carries (#4463 D1/D3, #4717): `PublishMetaItemResponseSchema` declares it (`RuntimeAuthoringIssueSchema` elements, declared once in `@objectstack/spec`), and `publishMetaItem` attaches the findings the promotion-time gate run returns instead of discarding them. A clean publish's response bytes are unchanged — the key is present only when at least one `warning`/`info` finding was raised; `error` findings still refuse the promotion as the 422 envelope. This matters most for Studio / MCP / AI authors, whose designer takes draft-then-publish on every edit and has no CLI to surface the same findings.
+- 30d3752: fix(spec): `record:chatter` / `record:discussion` `position` speaks the renderer's vocabulary, and the row's schema defaults are dropped (#8762)
+
+ **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
+ launch-window convention ships it as `minor`; the migration prescription is
+ registered under protocol major 18, where `os migrate meta` users will look).
+
+ `RecordChatterProps.position` declared `sidebar | inline | drawer` — a
+ vocabulary NO renderer read point ever compared. Measured at objectui pin
+ `665661ab0932`, the renderer chain is self-consistent in three places and
+ speaks `bottom | right | left`: `RecordChatterPanel` docks on `right`/`left`
+ and renders in flow on `bottom`, the designer registration publishes
+ `enum: ['bottom', 'right', 'left']`, and the renderer merge falls back to
+ `bottom`. So the schema's own default (`sidebar`, materialized onto every
+ parsed node that said nothing) was a silent no-op falling through to the
+ in-flow render, while the value that actually docks the panel (`right`) was
+ refused at publish. The maintainer ruling (2026-08-15) converged the row on
+ the renderer's vocabulary — one vocabulary, no mapping layer.
+
+ **FROM → TO:** `position: 'sidebar'` → `'right'` (the docked side panel the
+ spelling meant); `'inline'` → `'bottom'` (the in-flow branch it already
+ landed in); `'drawer'` → `'right'` (no overlay drawer was ever implemented —
+ the docked panel is the nearest surviving intent). One-line fix: re-spell
+ `position` to `bottom`/`right`/`left`; `os migrate meta` rewrites sources
+ mechanically via the ADR-0087 conversion
+ `record-chatter-position-vocabulary`, and stored `sys_metadata` rows replay
+ clean through the rehydration seam. A live author gets a per-value "was
+ removed" prescription from the enum's own error map.
+
+ **All three schema defaults are dropped** (`position: 'sidebar'`,
+ `collapsible: true`, `defaultCollapsed: false`) per the `maxVisible`
+ principle — renderer fallbacks stay the renderer's facts. The old
+ `collapsible` default *inverted* the renderer merge's own `false` fallback,
+ turning "the author said nothing" into "the author asked for collapsible". A
+ page that wants the collapse affordance authors `collapsible: true`
+ explicitly; unset keys now parse to nothing and the renderer decides.
+
+ The row stays ONE shared schema object for `record:chatter` AND
+ `record:discussion` (the #8744 pairing) — both names accept and refuse
+ identically. The objectui renderer is unchanged.
+
+
+- 079b457: Retire `BATCH_PARTIAL_FAILURE`, `BATCH_COMPLETE_FAILURE` and `TRANSACTION_FAILED` from `StandardErrorCode` (ADR-0112 amendment 2026-08-18, ADR-0049 enforce-or-remove, #9266). Breaking for the error vocabulary: the three spellings now fail `StandardErrorCode` / `ApiErrorSchema` parse. No producer has ever emitted any of them — the batch surface reports these conditions per row instead, with strictly more information.
+
+ FROM → TO: `error.code === 'BATCH_PARTIAL_FAILURE' | 'BATCH_COMPLETE_FAILURE' | 'TRANSACTION_FAILED'` (envelope-level, never emitted) → read the per-row `results[].errors[].code` — a rolled-back atomic batch marks each row `ROLLED_BACK`, rows the abort never reached `NOT_ATTEMPTED`, and the causal row keeps its own error (HTTP 200, both codes ledger-registered). One-line fix: delete any branch on the three retired spellings (it never fired) and branch on the per-row codes instead.
+
+
+- 7a537ce: feat(spec): refuse unknown top-level stack keys — `ObjectStackDefinitionSchema` goes strict (#8687, the outermost #4001 door)
+
+ **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 top-level stack definition was the last strip-mode authoring surface of
+ the #4001 campaign: an unknown top-level key parsed green and its value was
+ silently dropped. Measured on 17.0.0 GA (#8687): three injected bogus
+ top-level keys added ZERO warnings to `os validate` and exited 0 — even
+ `--strict` could not catch them, because the `defineStack:` naming diagnostic
+ printed at load, outside the warning tally. The failure population is a typo
+ or stale key (`flow` for `flows`, `approvalProcesses` after the 7.4 removal)
+ shipping an artifact with a whole metadata family absent at runtime — the
+ root of hotcrm#1141.
+
+ **What is refused:** any top-level key the schema does not declare, with a
+ prescriptive message naming the surface and the offending key. A near miss
+ carries the did-you-mean the load-time lint used to print (`objectz` →
+ `objects`, `flow` → `flows`) — the near-miss resolver survives, now riding
+ the refusal itself, and `lintUnknownStackKeys` goes quiet on the strict
+ surface by its own posture rule (one voice, not two). Curated prescriptions
+ answer the known retirements: `storage` (deployment config, `OS_STORAGE_*`),
+ `approvals`/`approvalProcesses` (Approval-node flows, ADR-0019), `workflows`
+ (`state_machine` validation rules, ADR-0020), `portals` (removed, #3464),
+ `onDisable` (never invoked, #4212).
+
+ **What stays accepted:** every declared key byte-identically — and `onEnable`
+ is now DECLARED rather than undeclared-but-honoured: `AppPlugin` has always
+ executed it off the authored bundle (#4095 grafts it back on artifact boot),
+ and a strict close of an undeclared `onEnable` would have refused the pattern
+ our own examples ship. `declared = honoured`, in both directions.
+ `composeStacks` treats `onEnable` as single-valued: same value passes, a
+ disagreement is refused naming both stacks.
+
+ A strict parse failure fails `os validate` outright — exit 1 with or without
+ `--strict` — so the CI gap closes with no warning-accounting change.
+
+ ## FROM → TO
+
+ ```ts
+ // before — parsed green; the whole flows family was silently absent at runtime
+ export default defineStack({ manifest, objects: [...], flow: [myFlow] });
+
+ // after — refused at parse: "Unrecognized key(s) on this stack definition:
+ // `flow`. Did you mean `flow` → `flows`?"
+ export default defineStack({ manifest, objects: [...], flows: [myFlow] });
+ ```
+
+ There is deliberately no automatic rewrite: an undeclared top-level key
+ either names a capability the declaration surface does not deliver (blessing
+ it would be declared-but-unenforced surface, ADR-0078) or is a spelling of a
+ declared one, which the rejection names. `os migrate meta` surfaces the
+ change as a structured TODO (semantic entry
+ `stack-top-level-unknown-keys-refused`, protocol major 18 — this refusal is
+ not part of the v17.0.0 cut).
+
+
+- 593c4bf: feat(spec): `storage` becomes the canonical `CoreServiceName` slot; `file-storage` stays a deprecated v17 alias (#9683)
+
+
+
+ Maintainer ruling, 2026-08-18, verbatim: 「9683 file-storage 可以叫 storage」.
+ The `file-storage` slot was the only `CoreServiceName` member whose spelling
+ diverged from its documented accessor (`services.storage`), with no recorded
+ reason anywhere in the tree.
+
+ - `CoreServiceName` gains `storage` as the canonical member; `file-storage`
+ stays an accepted, deprecated alias within v17 (it is a published enum
+ member — existing `getService('file-storage')` callers keep working).
+ `CORE_SERVICE_PROVIDER` and `ServiceRequirementDef` carry both.
+ - `@objectstack/service-storage` registers the **same instance** under both
+ names (the `http.server` / `http-server` pattern), pinned by an
+ alias-equivalence test.
+ - Every internal consumer resolves `storage`: the HTTP dispatcher, the email
+ plugin's attachment store, and `os migrate files-to-references`. Discovery
+ reports the service under the canonical `storage` key and mirrors the row
+ verbatim under the `file-storage` key for the alias's v17 lifetime, so
+ existing discovery readers (e.g. the console endpoint catalog) keep
+ working.
+ - Docs (`kernel/runtime-services`, `kernel/contracts`) now document the
+ canonical slot; a custom v17 provider for this slot should register both
+ names.
+- 90c5285: Add the `map` visualization config block to `ListViewSchema` — the eighth
+ per-visualization block, alongside kanban / calendar / gantt / gallery /
+ timeline / chart / tree. `ListMapConfigSchema` (named like
+ `ListChartConfigSchema`, because the automation `map` flow node already exports
+ `MapConfigSchema`) declares the map renderer's documented read surface:
+ `latitudeField`, `longitudeField`, `locationField`, `titleField`,
+ `descriptionField`, `zoom` (1-20), `center` (`[latitude, longitude]`). All keys
+ are optional and none carries a default — when no camera is declared the
+ renderer fits the camera to the queried records. Before this block a
+ `type: 'map'` list view could not declare its field mapping at all
+ (`ListViewSchema` is strict), so a marker title field other than the renderer
+ default `name` was unreachable — the showcase task map rendered every marker
+ title as `undefined`. The showcase task map view now declares
+ `map: { titleField: 'title', locationField: 'location' }`.
+- 7901b2d: feat(spec): stamp-only `tenancy.organizationField` — audit rows can follow the record's organization on objects that must stay unwalled (#8778, closes the #8707 remainder)
+
+ The platform had one answer to "what is this object WALLED by"
+ (`tenancy.tenantField`) and no answer to "which column says who this row is
+ ABOUT". For ordinary objects the two coincide; for credential tables they
+ deliberately do not — `sys_api_key` records the organization a key
+ authenticates into under `active_organization_id` precisely so the credential
+ table is not org-walled (#8287). #8777's schema-resolved audit stamping could
+ therefore reach every shipped object except the one that motivated it, and
+ revocation rows on `sys_api_key` kept stamping the revoker's organization.
+
+ `TenancyConfigSchema` now accepts an optional `organizationField` — a
+ READ-NEUTRAL, STAMP-ONLY declaration (maintainer-ruled option A on #8778):
+
+ - The audit writer's `resolveRecordOrganizationField` consults it first, ahead
+ of the ADR-0066 `enabled: false` opt-out — an author declaring it on an
+ unwalled object is stating exactly that the audit trail should follow the
+ record's own organization even though no wall does. It is honoured only when
+ the object really has the field (the #5315 guard `tenantField` carries).
+ - No read path reads it: `applyTenantScope`, `injectTenantOnInsert`,
+ `computeTenantLayer0Filter` and `resolveInjectedSystemColumns` are all
+ measured blind to it, and that read-neutrality is pinned by tests beside
+ each. Declaring it never walls an object and never hides rows.
+ - ⛔ Scope pin from the ruling: this is ONE stamp-only key, not the opening
+ move of a general field-roles mechanism. A consumer other than audit
+ stamping needs its own ruling before reading it.
+
+ `sys_api_key` now declares
+ `tenancy: { enabled: false, organizationField: 'active_organization_id' }`,
+ so revoking another user's key from a different active organization lands the
+ audit row behind the wall of the KEY's organization — where the tenant admin
+ who can act on it reads it. The `enabled: false` is measured
+ behavior-identical to the previous absent block for this object on every read
+ path (injection bails on `managedBy: 'better-auth'` first; the SQL driver's
+ tenant field resolves null either way; Layer 0 is exempt either way; the
+ memory/mongo boot guards count only an explicit `enabled: true`).
+- 79394d7: feat(spec): declare `record:alert` / `record:quick_actions` / `record:history` / `record:discussion` in `ComponentPropsMap` — undeclared keys on the four are refused (#8744)
+
+ **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
+ launch-window convention ships it as `minor`; the migration prescription is
+ registered under protocol major 18, where `os migrate meta` users will look).
+
+ These were the four `record:*` types #8691's rail fix left in the rail's own
+ pre-fix position: a registered objectui renderer, a `PageComponentType` entry
+ and a console palette slot (bar `record:discussion`, which was authorable only
+ through the type union's open string arm), and no `ComponentPropsMap` row — so
+ the #5068 component-props gate's dispatch skipped them as unregistered and
+ every authored key rode through. A typo'd `severty` on the platform's own
+ banner surface parsed, typechecked, validated, built and shipped as a silent
+ no-op while sibling components in the same file drew loud diagnostics.
+
+ The new rows are strict and declare exactly what the renderers read, measured
+ from their read points at the objectui pin — not from the registrations'
+ declared-input lists, which are wrong in both directions here:
+
+ - `record:alert` — `severity?`, `title?` / `body?` (string **or inline locale
+ map** — this renderer resolves both through `pickLocalized`, the opposite
+ verdict from the rail's literal-string `title`, measured the same way),
+ `visible?` (boolean | CEL string | `{ dialect, source }` envelope), `icon?`
+ (read here, unlike the rail's), `action?` `{ actionName, label?, variant? }`,
+ `dismissible?`, `dismissKey?`. `visibleWhen` / `visibility` rename to
+ `visible` as aliases — this is the one record component whose props-level
+ predicate is real, so the wrong-layer visibility guidance does not apply.
+ - `record:quick_actions` — `actionNames?`, `requiredPermissions?`, `location?`
+ (the spec's own `ActionLocationSchema`, retirement prescriptions included),
+ `align?`, `inline?`, `variant?` / `size?` (the Button primitive's delivered
+ vocabulary). `actions` is refused with a prescription (as a name list it is
+ `actionNames`; as inline defs it is the host synthesizer's runtime channel).
+ `aria` is refused rather than declared: the renderer reads `aria.label`, a
+ spelling the shared `AriaPropsSchema` refuses, and reads nothing else of the
+ bag — declaring either spelling would be declared-but-unenforced surface
+ (the renderer-side fix is objectui's, filed).
+ - `record:history` — `limit?`, `emptyText?` / `unknownUserText?` (literal
+ strings — the timeline renders them raw; a locale map would paint
+ `[object Object]`). `entries` / `loading` are refused as the host's data
+ channel: omit them and the block self-fetches the record's `sys_activity`
+ history.
+ - `record:discussion` — `record:chatter`'s own row, deliberately the same
+ schema object (one renderer registered under two names must keep one accept
+ face), plus a `PageComponentType` entry so the name is no longer a
+ string-arm stowaway.
+
+ **What stays accepted:** every declared key byte-identically — the platform
+ `sys_user` page's banner and self-service action bars and the showcase task
+ page pass with zero findings. No row carries a schema default (renderer
+ fallbacks stay the renderer's facts). The one parse-time normalization is
+ `ExpressionInputSchema`'s own: a bare-string `visible` becomes the canonical
+ `{ dialect: 'cel', source }` envelope.
+
+ ## FROM → TO
+
+ ```ts
+ // before — parsed green everywhere; the banner styled itself `info` anyway
+ {
+ type: 'record:alert',
+ properties: {
+ severty: 'warning', // silent no-op typo
+ title: 'Awaiting review',
+ },
+ }
+
+ // after — the typo is a publish-time refusal naming the rename; write the
+ // measured shape
+ {
+ type: 'record:alert',
+ properties: {
+ severity: 'warning',
+ title: 'Awaiting review',
+ visible: "record.status == 'in_review'",
+ },
+ }
+ ```
+
+ There is deliberately no automatic rewrite: an undeclared key is either a
+ spelling of a declared one (the rejection names the rename) or names a
+ capability the renderer does not deliver, and blessing either would be
+ declared-but-unenforced surface (ADR-0078). `os migrate meta` surfaces the
+ change as a structured TODO (semantic entry
+ `ui-record-blocks-unknown-keys-refused`, protocol major 18 — this refusal is
+ not part of the v17.0.0 cut).
+
+
+- 730fd9a: feat(spec): declare `record:reference_rail` in `ComponentPropsMap` — undeclared rail keys are refused (#8691)
+
+ **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
+ launch-window convention ships it as `minor`; the migration prescription is
+ registered under protocol major 18, where `os migrate meta` users will look).
+
+ `record:reference_rail` had a registered renderer, a `PageComponentType` entry
+ and a console palette slot, but no row in `ComponentPropsMap` — so the #5068
+ component-props gate's dispatch skipped it as unregistered and every authored
+ key rode through. Measured on 17.0.0 GA end to end: a planted entry `filter`
+ passed tsc, `objectstack validate` and `objectstack build`, shipped verbatim in
+ `dist/objectstack.json`, and the rendered rail kept counting and listing
+ unfiltered rows — while the very same build loudly reported
+ `record:related_list` keys in the same file.
+
+ The new row is strict and declares exactly the shape the renderer reads
+ (measured from its read points at the objectui pin, not from its TS
+ interface): `entries[]` of `{ objectName, relationshipField, title?, limit?,
+ displayField? }` plus a component-level `hideEmpty`.
+
+ **What is refused:** any key the shape does not declare, with a prescriptive
+ message — the planted `filter` (the rail issues one fixed query per entry;
+ `record:related_list` is where `filter` is real), the interface's `icon` (read
+ by no render path — declaring it would be declared-but-unenforced surface),
+ entry-level `hideEmpty` (a component-level key), and the neighbouring-surface
+ spellings `items`/`related` → `entries`, `object` → `objectName`, `label` →
+ `title`. `title` is a literal `z.string()` — the renderer paints it as a raw
+ React child, so an inline locale map is refused rather than shipped as
+ `[object Object]`.
+
+ **What stays accepted:** every declared key byte-identically. `limit` and
+ `hideEmpty` carry no schema default (the renderer's `3` / `true` fallbacks stay
+ the renderer's), so a minimal entry round-trips unchanged.
+
+ ## FROM → TO
+
+ ```ts
+ // before — parsed green everywhere; the badge kept counting everything
+ {
+ type: 'record:reference_rail',
+ properties: {
+ entries: [{
+ objectName: 'task', relationshipField: 'project_id',
+ filter: [{ field: 'status', op: 'neq', value: 'completed' }], // silent no-op
+ icon: 'CheckSquare', // read by nothing
+ }],
+ },
+ }
+
+ // after — both keys are publish-time refusals with prescriptions; write only
+ // what the renderer reads
+ {
+ type: 'record:reference_rail',
+ properties: {
+ entries: [{ objectName: 'task', relationshipField: 'project_id', limit: 3 }],
+ hideEmpty: false,
+ },
+ }
+ ```
+
+ There is deliberately no automatic rewrite: an undeclared key is either a
+ spelling of a declared one (the rejection names the rename) or names a
+ capability the rail does not deliver — a per-entry `filter` and an inline
+ `title` locale map are open capability questions for the console seat, and
+ blessing either spelling now would be declared-but-unenforced surface
+ (ADR-0078). `os migrate meta` surfaces the change as a structured TODO
+ (semantic entry `ui-reference-rail-unknown-keys-refused`, protocol major 18 —
+ this refusal is not part of the v17.0.0 cut).
+
+
+- d634e66: feat(spec): export `urlUserinfoUsername` — the username half of the shared URL userinfo grammar (#8876)
+
+ `@objectstack/spec/data` owns the DSN userinfo grammar (`urlUserinfoPassword` /
+ `redactUrlPassword`, #8082/#8300) but exported only its password half. The
+ mongo DSN arm (#8696) must inject a bound `external.credentialsRef` secret via
+ `MongoClient`'s `auth` option, which requires the username the URL already
+ names — and reading it needs this grammar, because `new URL()` throws
+ `ERR_INVALID_URL` on the multi-host DSN form `MongoConfigSchema` documents
+ (`mongodb://app@h1:27017,h2:27017/app`, measured). A local copy in
+ `service-datasource` is the shape the #8082 single-parse ruling refuses by
+ name.
+
+ **Additive only.** The new accessor shares the password half's boundary parse
+ by construction (both now call one internal RFC-3986 userinfo parse), returns
+ the RAW component (percent-encoding preserved, decoding stays with the
+ caller), answers `''` for an empty username inside present userinfo and
+ `undefined` when the string carries no userinfo at all, and still parses the
+ publish-refused `user:password@` shape correctly — stored legacy rows carry
+ it, and #8155's migration path must judge exactly those rows. No Zod schema
+ changes: every input that validated before validates identically after; the
+ read-path redaction alignment pin now covers the username half too (redaction
+ preserves the username byte-for-byte).
+
+### Patch Changes
+
+- 56656aa: 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.
+- d9813a9: 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)
+- 985a9cd: 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)
+- 26e70fb: 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.
+- abcf853: docs(spec): `FieldSchema` points a bare `currency` key at the declarable `currencyConfig` form (#8163)
+
+ `currency` has never been a declared `FieldSchema` key — only `currencyConfig`
+ is. Writing the natural spelling was always a loud parse error, but a **bare**
+ one: the rejection carried only the surface history line ("Until #4001 closed
+ this shape these were dropped silently…"), with no pointer to the declarable
+ form. The spelling is not hypothetical — objectui's `resolveFieldCurrency`
+ reads `field.currency` first from looser grid/column configs, so it circulates
+ in configs an AI author will have seen.
+
+ The target is a NESTED key (`currencyConfig.defaultCurrency` under
+ `currencyMode: 'fixed'`), which a flat `aliases` rename cannot express — so
+ this is prose (`guidance`), the same `storageNotNull`-style case already on
+ this surface: `currency` is not a field key; a fixed currency is declared as
+ `currencyConfig: { currencyMode: 'fixed', defaultCurrency: '…' }`. A field
+ without one uses the tenant default at runtime.
+
+ Accept/reject is byte-for-byte unchanged — `currency` was rejected before this
+ change and stays rejected after it; only the rejection's message gains a
+ prescription.
+- dd88e1c: 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.
+- 856527c:
+
+ docs(spec): register the FILTER-axis formula refusal in the ADR-0087 ledger (#8370)
+
+ The refusal itself shipped in 17.0.0 (#8296 / PR #8369): a `where` naming a
+ `formula` field is `400 INVALID_FIELD` at both doors — the REST ingress
+ (`assertFilterFieldsExist`) and the engine's own filter seam
+ (`assertFilterIsMaterializable`), which saved reports, flows and dashboard
+ widgets reach directly. It shipped with **no** ADR-0087 semantic entry, so
+ `objectstack migrate meta`, `spec-changes.json` and the generated upgrade guide
+ said nothing about it.
+
+ Its SORT-axis twin (#7095, `engine-find-formula-order-by-refused`) carries one,
+ for the identical shape. This adds the FILTER-axis sibling —
+ `engine-find-formula-filter-refused` under protocol 17 — and regenerates the two
+ projections of the registry.
+
+ For a code-path API there is no `sys_metadata` row for the D2 chain to rewrite
+ and no mechanical rewrite in either direction (the platform cannot invent the
+ stored column, and it must not filter post-hoc — `driver.find` has already
+ applied `limit` / `offset`, so a post-hoc predicate would filter an arbitrary
+ PAGE), which makes the ledger entry the only notification channel this class
+ has. The remedy it prescribes is the one the sort and search axes already
+ prescribe, in the same words: denormalise the value onto a stored field written
+ when the source changes, and filter that. `summary` and `autonumber` fields need
+ no action — both get real maintained columns and filter correctly.
+
+ No behaviour changes: registration and regenerated artifacts only.
+- 29d055b: 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.
+- e196c6a: 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.
+- 739fe5b: fix(spec): enforce the list-comparand rule at the shared compile face, so a scalar `in`/`nin` no longer reaches a driver (#9228)
+
+
+
+ `FieldOperatorsSchema` has always declared `$in` / `$nin` as `z.array(z.any())`
+ and `$between` as `z.tuple([min, max])`, and #5869 / PR #6209 built the gate that
+ enforces it — but only at `@objectstack/objectql`'s lowering seam. That covers
+ every query reaching a driver **through the engine** and nothing else. A caller
+ that lowers a filter with `parseFilterAST` and calls a driver directly — an
+ embedder, and this repo's own driver conformance suites — met no gate at all:
+ `parseFilterAST([['name', 'notin', 'alpha']])` returned `{ name: { $nin:
+ 'alpha' } }`, a shape the contract forbids, and handed it over.
+
+ That path was carried by mingo's own coercion of a non-array `$in`/`$nin`
+ operand. mingo 7.2.3 removed the coercion, so from 7.2.4 on the same input
+ escapes as an unhandled third-party `TypeError: b.filter is not a function` —
+ no `code`, no `status`, no field name — straight to the caller. It is the sole
+ failure blocking the `mingo` 7.2.2 -> 7.2.4 bump.
+
+ **Fixed at the shared face, with exactly one implementation.** The rule now
+ lives in `@objectstack/spec`'s `data/filter-comparand-shape.ts`, the same place
+ the comparand-TYPE door (#7872 / PR #8234) was promoted to for the same reason
+ ("enforced once at the shared compile face for all five drivers"), and
+ `parseFilterAST` runs it on everything it returns — shape first, then type, the
+ order the engine's own seam already applied. `@objectstack/objectql`'s
+ `assertListComparandShapes` is now a delegating wrapper whose only remaining job
+ is the engine's `find('deal'): ` caller prefix; no driver was patched (both
+ driver families are under the #5499 investment freeze).
+
+ **The accept/reject delta is narrow and one-directional.** Newly refused, with
+ the ADR-0112 `INVALID_FILTER` / 400 envelope: a non-array `$in` / `$nin`
+ comparand and a non-`[min, max]` `$between` comparand, reaching a driver via a
+ direct `parseFilterAST` call. Nothing else changes — every filter the engine
+ accepted still lowers byte-identically, `$in: []` / `$nin: []` remain legitimate
+ declared predicates, list MEMBER types stay unjudged here, and a field spec with
+ no `$` key is still not descended into. The same inputs were already refused
+ with the same envelope on every engine verb and at the REST ingress, so no
+ authored metadata in the repo or in `objectui` produces a shape that newly
+ fails: a survey of `examples/**`, `content/docs/**`, fixtures, seeded platform
+ objects and objectui's view definitions found every membership rule already
+ carrying an array.
+
+ `parseFilterAST` gains an optional second argument, `context` — the caller
+ prefix both doors in `@objectstack/spec` already take. It is additive and
+ defaulted; existing calls are unaffected.
+- e4e5c6e: fix(spec): correct `MongoConfigSchema.options`'s field description to state the actual refusal boundary — only `auth.password` is refused inline; `proxyPassword`, `tlsCertificateKeyFilePassword`, `key`, and `passphrase` are accepted, stored at rest in cleartext, and redacted only on read (#9254)
+
+ The old string claimed "credential material is refused" for the whole `options`
+ passthrough. That was true for exactly one nested path
+ (`options.auth.password`, `MONGO_OPTIONS_CREDENTIAL_PATHS` / #9040) — four
+ other honoured, credential-shaped keys were never refused, only redacted when
+ a datasource is read back (`PASSTHROUGH_SECRET_PATHS` in
+ `datasource-credential-redaction.ts`). This string renders verbatim into
+ `content/docs/references/data/driver-mongo.mdx` and the Studio "Add
+ Datasource" connection form's field help text, so an author configuring a
+ proxy password or a TLS key passphrase was told it would be refused when it
+ would actually be accepted and stored in cleartext.
+
+ Describe-only: no schema shape or refusal-path change — every previously-valid
+ `options` input still parses byte-identically. The corrected text agrees with
+ the accurate statement #9124 landed in `content/docs/data-modeling/drivers.mdx`.
+- 9a56784: fix(spec): correct the stale "passed to `mysql2` verbatim" TS doc comment on `MysqlConfigSchema.ssl` (mysql.zod.ts) — since #8874, the resolved `true` is translated into mysql2's own default TLS options (`rejectUnauthorized: true`) before mysql2 sees it, because mysql2 rejects a bare boolean outright. Comment-only; accept/reject behaviour is unchanged, and the shared `DriverSslToggleSchema.describe()` (correct for the postgres/turso arms, which do pass the boolean verbatim) is untouched (#9125)
+- d00d2f6: fix(driver-sql): refuse — and roll back — a MySQL upsert that merges onto a row the caller never identified (#8807)
+
+ `ON DUPLICATE KEY UPDATE` carries no conflict target, so on MySQL a merge lands on
+ whichever UNIQUE key the row collides with first. `#8621` closed the half where
+ nothing backed a caller-named target; `#8755` closed the half where a rival key
+ could absorb a caller-named one. This closes the residue those two left by
+ construction: the `conflictKeys`-less call and the `['id']` call, which compile
+ byte-identically and which no pre-flight can judge, because neither names anything.
+
+ Measured on live MySQL 8.0.46, `email` and `tax_id` both `unique: true`, **no**
+ `conflictKeys`: seeding `{email:'d@b.com', tax_id:'T-9'}` inserted one row, and
+ `{email:'e@b.com', tax_id:'T-9'}` then resolved with no error — one row, the
+ *seeded* one, its `email` rewritten `d@b.com` to `e@b.com`, and the id the caller
+ was handed back present in no row at all. The identical pair on SQLite raises
+ `UNIQUE constraint failed: …tax_id` and leaves the seeded row untouched.
+
+ Per the maintainer ruling on #8807 this enforces a contract principle, not a MySQL
+ detail: *an `upsert` must never modify a row whose identity the caller did not
+ supply and whose conflict key it did not name.*
+
+ **Accept-set change, MySQL only.** After the statement and inside the same
+ transaction, the driver checks whether the row it landed on is the one the call
+ supplied. If it is not, the write is **rolled back** and the call refuses with
+ `code: 'VALIDATION_ERROR'`, `status: 400`, naming the UNIQUE key that absorbed the
+ merge and stating that nothing was changed.
+
+ The check is exact rather than heuristic — `id` is insert-only on the merge path
+ (#8622), so a row merged on the primary key always still carries the supplied id
+ and a row merged on any other key never does — which is why it has no false
+ refusals.
+
+ Deliberately unchanged: tables whose only key is the primary key are not verified
+ and open no transaction, so the ordinary upsert keeps its single round trip; every
+ insert and every re-upsert of the same row still merges; the caller-named
+ single-unique-key fast path is untouched; and SQLite and PostgreSQL are unaffected,
+ because `ON CONFLICT (...)` already honours the named arbiter. The lifecycle
+ archiver's hot→cold copy passes by construction — it supplies each row's own id —
+ and of the two objects declaring `lifecycle.archive`, neither carries a
+ non-primary unique field. The dialect limit is documented under
+ *Database Drivers → MySQL*.
+- df0c12d: `FieldSchema` docs now pin the ruled multi-value lookup empty representation (#9447, maintainer ruling 2026-08-18): an emptied multi-value lookup reads back as `[]`, never `null` — binding for every writer (cascade repair, form clears, API writes) — and `required` on a multi-value lookup means non-empty array, so an emptied required set fails validation loudly.
+- 1a7f907: fix(metadata): a package publish refuses a draft stored under a non-canonical metadata type, and the ADR-0010 audit writer asserts its `type` instead of folding it (#8908)
+
+
+
+ **Two tightenings, one card, because they are the same defect at two layers.**
+
+ `publishPackageDrafts` reads `sys_metadata` rows **at rest**, so #7894's `/meta`
+ boundary fold never reached it. `promoteDraftForPublish` folds the stored
+ spelling through `PLURAL_TO_SINGULAR` — the *manifest-collection* map, which
+ legitimately omits types that are not stack collections. For those the fold is a
+ **no-op**: the lookup key equals the stored spelling, the draft resolves, and the
+ publish mints an ACTIVE row in the namespace `PUT /meta/field/…` answers
+ 403 NOT_OVERRIDABLE for. Measured on the card with the real repository over a
+ stub engine:
+
+ ```
+ publishPackageDrafts({ packageId: 'app.demo' })
+ → { success: true, publishedCount: 1, published: [{ type: 'fields', name: 'legacy_field' }] }
+ active row: { type: 'fields', name: 'legacy_field', package_id: 'app.demo' }
+ audit row: { type: 'fields', name: 'legacy_field', outcome: 'allowed', code: 'ok' }
+ ```
+
+ Every registry read and every compliance query on `field` misses an item the
+ platform just reported as published — the #4432 shadowing shape, minted at
+ publish time instead of at the URL, and the last route by which a pre-#7894 row
+ could be re-promoted rather than migrated.
+
+ **1. The publish refuses it, at the pre-flight, batch-atomically.** Same shape as
+ the ADR-0028 namespace-prefix gate that already stands there: found before
+ anything is promoted, failing the whole batch (`publishedCount: 0`,
+ `published: []`) rather than publishing the healthy siblings around it, with one
+ audit row per violation. The refusal names the row, names the canonical type, and
+ states the re-author path; `failed[].code` is the new
+ `STORED_TYPE_NOT_CANONICAL`, and the audit column's spelling is
+ `stored_type_not_canonical`.
+
+ The rule is **derived, not a list**: a spelling the platform's URL/registry map
+ folds elsewhere *and* the manifest map leaves unchanged. Against the real maps
+ that is **six** spellings — `fields`, `seeds`, `external_catalogs`,
+ `externalCatalogs`, `translations`, `email_templates` — where the card named
+ four; the last two would have been missing from any hand-written list, and a
+ newly declared type that never reaches the manifest map is covered on the day it
+ is declared. A manifest-**present** plural (`objects`) is deliberately *not* in
+ the class: it is already fail-closed at the promote (`NO_DRAFT`, batch aborted)
+ and keeps that verdict.
+
+ ⛔ Deliberately **not** included: migrating the row (a `_migrate-stored` /
+ boot-reconciliation conversion). That was the other option on the card and is
+ explicitly unruled — it stays available as a follow-up with its own appetite.
+
+ **2. `recordMetadataAudit` refuses a non-canonical `type` (`AUDIT_TYPE_NOT_CANONICAL`)
+ instead of folding it.** The writer used to open with
+ `type: PLURAL_TO_SINGULAR[entry.type] ?? entry.type` — a lenient consumer, and a
+ **tolerant-and-incomplete** one: the fold read the same manifest map, so the
+ compliance trail came out canonical for the 29 types that never needed it and
+ non-canonical for exactly the ones that did. Ruled the same direction as the
+ refusal above: **fold at the boundary, assert at the writer.** Every call site
+ that builds a row out of an at-rest `type` — all of them on
+ `publishPackageDrafts` — now folds with `canonicalMetaType`; the `/meta` routes
+ were already canonical by the time they got there. The throw sits **outside** the
+ writer's best-effort `try`, because inside it the method's own `catch` would
+ degrade the assert into a `console.warn`.
+
+ The assert cannot refuse a canonical type (no canonical spelling folds
+ elsewhere — 33 of 33, measured) nor a plugin-registered or otherwise
+ unrecognised kind (`canonicalMetaType` is the identity for anything the static
+ map does not carry), so it narrows the accept set without closing it.
+
+ **Reachability was enumerated before the assert landed**, as the ruling required:
+ `recordMetadataAudit` is private to `protocol.ts` with 11 call sites, `sys_metadata`
+ rows have exactly one producer in the repository (`saveMetaItem` → `repo.put`,
+ post-fold), and no current write path can mint a non-canonical stored type. The
+ only non-canonical types that ever reached an audit write came from the batch
+ publish's at-rest rows, which is what the boundary folds now cover.
+
+ Also fixed, as a consequence of that fold rather than as a separate change: on
+ the batch route `getEffectiveLock`'s overlay limb was queried with the raw stored
+ spelling, so an ADR-0010 `_lock` carried by the canonical active row was looked
+ up under a `type` no row has and came back `'none'` — the verdict "the author
+ declared no protection". That is the batch twin of the hole #8769 closed on
+ `publishMetaItem`.
+- cd455c8: docs: four published READMEs stop documenting symbols and call sites that do not exist (#9544)
+
+ All four packages ship `README.md` in their `files` array with `private` unset, so these
+ are the pages npm renders. Each finding was re-measured against the **built `.d.ts`**, not
+ against source, because that is what a consumer resolves through the `exports` map.
+
+ - **`@objectstack/driver-sql`** — `import type { IDriver } from '@objectstack/spec'` named
+ a type that exists **nowhere in the repository** (0 hits across every package's `src`
+ and `dist`). The real contract is `IDataDriver` on `@objectstack/spec/contracts` — the
+ one `SqlDriver` actually declares (`export class SqlDriver implements IDataDriver`). The
+ adjacent operation list was corrected too: the method is `create`, not `insert`.
+
+ - **`@objectstack/mcp`** — `DriverSql` has never existed (the export is `SqlDriver`), and
+ the README then called `DriverSql.configure({...})` on it. Renaming alone would have
+ been wrong twice over: `SqlDriver` has **no static `configure` either**, and `driver:`
+ is not a key of `defineStack` at all. The example now declares a datasource the way the
+ shipped templates do. `MCPServerPlugin.configure({...})` — five call sites — becomes
+ `new MCPServerPlugin({...})`, the form the class's own JSDoc and every in-repo caller
+ use. The documented options block claimed `serverName`, `autoRegisterTools`,
+ `autoExposeObjects`, `enableStreaming`, `port` and `debug`; the real
+ `MCPServerPluginOptions` is `name`, `version`, `transport`, `autoStart`, `instructions`,
+ and the env switches are named instead.
+
+ - **`@objectstack/objectql`** — `registerObject` is an **instance** method, so
+ `SchemaRegistry.registerObject(...)` on the class could never run. The example now
+ reaches it through the engine's registry and states the real parameter order
+ (`schema, packageId, namespace?`).
+
+ - **`@objectstack/spec`** — the protocol package's own front page imported
+ `MCPServerConfigSchema` from `@objectstack/spec/ai`, which exports `MCPServerRefSchema`.
+ A rename by itself would have swapped a broken import for a broken **parse**: the
+ documented payload was built for a schema that does not exist, and
+ `MCPServerRefSchema.safeParse` rejects it (`transport` is an enum of
+ `stdio | http | websocket`, not an object, and `endpoint` is required and was absent).
+ The example is now a payload that parses green, and the page says plainly that tools,
+ resources and prompts are derived from metadata at runtime rather than authored there.
+- c80e7ae: fix(spec): reference tables stop marking `.default()`-bearing members as required, and name the default instead (#8703)
+
+ The Required column of every `content/docs/references/**` property table mirrored
+ the emitted JSON Schema's `required` array. `build-schemas.ts` emits the
+ **output** (post-parse) shape for 1458 of the 1582 published documents, falling
+ back to the **input** shape only when output emission throws — and in an output
+ shape a `.default()`-bearing member is listed in `required`, because the parse
+ always produces it. So the column answered "must I write this?" with `✅` for
+ keys the author may freely omit.
+
+ **Measured on the emitted tree: 2526 property occurrences across 529 documents**
+ were in `required` while carrying a `default`. `kernel/metadata-plugin.mdx` is
+ the specimen the card was filed on — `enableEvents`, `validateOnWrite`,
+ `enableVersioning`, `cacheMaxItems` and `bootstrap` all read `✅`, and all five
+ are omittable.
+
+ Two consequences, both fixed here:
+
+ - Reference tables are read far more often by an AI author than by a human
+ (ADR-0033), and omitting optional keys is that author's normal mode. A wall of
+ `✅` teaches over-specification, and buries the genuinely-required keys among
+ the ones that are not.
+ - The same member rendered `✅` on an output-shape page and `optional` on one of
+ the 124 input-shape pages, so a refactor that merely flipped a def between the
+ two emission modes rewrote its whole Required column with no semantic change to
+ what an author writes.
+
+ **The fix reads `default` rather than `required`**: a property carrying a
+ `default` is author-omittable by construction in *both* emission modes, so it now
+ renders `optional (default: \`false\`)` — strictly more information than either
+ previous cell, since the value an author gets by omitting the key was nowhere on
+ the page before. A structural default too wide for the cell renders
+ `optional (has default)` (13 cells; the budget's discontinuity is documented at
+ `INLINE_DEFAULT_WIDTH_LIMIT`), and a property with no default is untouched in
+ both directions.
+
+ **The JSON Schemas are deliberately unchanged.** `build-schemas.ts` is not
+ touched by this fix: the emitted artifacts keep describing the post-parse shape
+ and keep validating post-parse data. Only the doc renderer reads the author's
+ question differently. 146 reference pages are regenerated.
+- 09a9a8a: Register, in `ERROR_CODE_LEDGER`, the seven error codes the dispatcher
+ error-vocabulary gate (#8087, maintainer ruling 2026-08-12: option B delivered
+ as a gate) reported as reaching a wire `error.code` with no ledger row — so the
+ bodies that carry them parse against `ApiErrorSchema` instead of failing the
+ schema they claim to satisfy:
+
+ - `FLOW_FAILED` (`@objectstack/runtime`) — a flow that ran and rejected (#3962)
+ - `QUERY_OBJECT_MISMATCH` (`@objectstack/metadata-protocol`) — query body's
+ `object` key names a different object than the route
+ - `ERR_AUTONUMBER_COLLISION`, `ERR_TRANSACTION_UNSUPPORTED`,
+ `ERR_CROSS_DATASOURCE_TRANSACTION_WRITE`, `ERR_HOOK_TARGET_REBIND`
+ (`@objectstack/objectql`) — the unswept members of the package's `ERR_*`
+ family
+ - `FIELD_VISIBILITY_UNRESOLVED` (`@objectstack/rest`) — ADR-0106 D6 tier 3
+ fail-closed 503
+
+ Owning packages follow #7504 provenance (the package whose source stamps the
+ code). No wire value changes: every code was already emitted; the ledger now
+ admits what is measured on the wire. `STORAGE_FAILURE` (producer-less) and
+ `DUPLICATE` (the pinned witness of the sandbox-authored limb, #9106) are
+ deliberately not registered.
+- 07026cf: Register `FLOW_CONVERSION_CONFLICT` (409) in the ADR-0112 error-code ledger under
+ `@objectstack/metadata-protocol` (#9567). The code was already live on the wire —
+ `saveMetaItem`'s flow-conversion rename guard (`protocol.ts`) has thrown it since
+ ADR-0078 landed, already SCREAMING_SNAKE — but was invisible to
+ `check:dispatcher-error-vocabulary`'s scan because the site stamps it through a
+ cast (`(err as any).code = 'FLOW_CONVERSION_CONFLICT'`) rather than the bare-
+ identifier `assign` shape the scan matched at the time. This is an ordinary,
+ additive admission: no accept/reject behavior, no producer, and no wire shape
+ changes.
+- 5d4f3d5: Register `UNIQUE_SCOPE_CONFIRMATION_REQUIRED` (`@objectstack/cloud-connection`) in `ERROR_CODE_LEDGER` — the ADR-0120 D5e posture-gate refusal the marketplace install seam answers (409) when an app declares installation-wide unique constraints under the `isolated` tenancy posture. The code was already on the wire with a live reader (`os package install` branches on it to print the per-index decision list) but sat outside the closed ADR-0112 vocabulary (`StandardErrorCode ∪ ERROR_CODE_LEDGER`), invisible until #9223 taught the dispatcher-vocabulary gate to see a constant stamped in an object literal. No wire behavior changes — the value was already emitted; `ApiErrorSchema` now accepts what the wire actually carries. The now-discharged `pending-registration` row ratchets out of `packages/runtime`'s dispatcher-error-vocabulary table in the same change.
+- 4d80e8b: fix(spec): `IJobService` JSDoc stops calling `sys_job_run` "the audit trail" — it's job run history (#9673)
+
+ `packages/spec/src/contracts/job-service.ts` called the storage `replay()` and
+ `JobRunOutcome.reason` write to "the execution audit trail" / "the audit
+ trail" in three spots. The binding #9633 ruling: `sys_job_run` is **job run
+ history**, not the audit trail — `sys_audit_log` is the audit surface, with
+ its own opt-in, writer and retention. Published `.d.ts` tooltip text pointing
+ readers at the wrong subsystem was exactly the conflation that ruling
+ rejected.
+
+ Wording only — `reason?`, `replay?()` and their runtime behavior are
+ unchanged. `replay`'s JSDoc also gains the caveat #9673 suggested: recording
+ anything durable depends on an adapter that persists run history at all
+ (e.g. `DbJobAdapter`'s `recordRuns` option), since #9633 made that
+ conditional where the prose previously read as unconditional.
+- 30b1c63: Register the nine `@objectstack/rest` wire codes the #8885 population sweep measured outside the closed ADR-0112 vocabulary (`StandardErrorCode ∪ ERROR_CODE_LEDGER`): `THROTTLED` (429, the approvals remind cool-down rejection the spec contract documents) and the eight template-generated `APPROVAL__FAILED` terminal 500 codes (`APPROVE`, `REJECT`, `REVISE`, `RESUBMIT`, `REASSIGN`, `REMIND`, `REQUEST_INFO`, `COMMENT`) whose literal-spelled siblings were already registered. No wire behavior changes — these values were already emitted; `ApiErrorSchema` now accepts what the wire actually carries.
+- e43b211: fix(spec): the retirement prescriptions state what `os migrate meta` actually does (#9529)
+
+ Every `retiredKey()` prescription whose surface an ADR-0087 conversion covers
+ closed with a maintainer-ruled sentence (2026-08-09, #6856):
+
+ > Run `os migrate meta --from N` to rewrite existing sources automatically.
+
+ The command has never rewritten an authored source file. It replays the
+ conversion chain over the loaded stack **in memory**, prints the attributed
+ mechanical change list (`Applied N mechanical change(s)`, one line per site as
+ `path: from → to (conversionId)`), and writes exactly one file — the `--out`
+ JSON snapshot, when you ask for it. Every write site in
+ `packages/cli/src/commands/migrate/meta.ts` is that snapshot; there is no
+ `--write` / `--fix` / in-place flag. So an author who followed the prescription
+ got the chain replayed, a printed diff and optionally a JSON document in a shape
+ their per-artifact `.ts` modules are not written in — and then still edited every
+ file by hand, with nothing in the message saying so.
+
+ Under the maintainer's ruling of 2026-08-18 the sentence is withdrawn in favour
+ of an honest one, class-wide:
+
+ > Run `os migrate meta --from N` to list the mechanical edits for existing
+ > sources; apply them by hand.
+
+ The partial-value conversions keep their two-clause shape, reworded the same way
+ (`… to list the mechanical edits for the \`1y\` case; the other durations are
+ reported for you to re-state.`). Behaviour is unchanged in both packages — this
+ is message text only, and no accept/reject verdict moves.
+
+ The claim is withdrawn from every shipped site, not only the canonical sentence:
+ the variant phrasings in tombstone and conversion-registry prose ("rewrites
+ author sources", "rewrites it for you", "only `os migrate meta` rewrites
+ sources") go with it, as do the upgrade-path statements in the hand-written docs
+ (`upgrading.mdx` now carries the same "does not rewrite your source files" fact
+ the `objectstack-upgrade` skill already told operators). The class-wide pin
+ `packages/spec/src/shared/retired-key-migrate-sentence.test.ts` moves in
+ lockstep and now holds **both** directions: the new sentence is required where a
+ prescription names the command, and the withdrawn claim is a hard failure
+ wherever it reappears — including in a prescription that spells the bare command
+ without `--from N`, which the sentence-shape check alone would not have seen.
+
+ The in-place AST codemod that would make the original claim true is commissioned
+ separately for v18 (#9591); when it lands, the sentence may be restored by
+ editing that one pin in the same PR.
+- 890b38f: Published agent-authoring prompts now reference real exports.
+
+ `prompts/create-new-project.md`, `prompts/implement-objectql.md` and
+ `prompts/implement-objectos.md` told agents to import five symbols that
+ `@objectstack/spec` does not export. Four failed loudly. The fifth did not:
+ `import { Object } from '@objectstack/spec/data'` does not resolve, so the
+ annotation in `export const AccountObject: Object = { ... }` bound to the
+ **JavaScript global** `Object` instead — metadata authored from that prompt
+ type-checked against a type that constrains nothing.
+
+ - Object definitions now use the house authoring convention measured in the
+ example apps, `ObjectSchema.create({ ... })`, which genuinely validates.
+ Correcting it exposed that the prompt's own example set `enable.audit` /
+ `enable.workflow`, neither of which exists; they are now `trackHistory` /
+ `files`, the pair the schema's own docstring uses.
+ - `implement-objectql.md` keeps the real `Field` and `QuerySchema` imports and
+ derives the object metadata type as `z.infer`, matching
+ both `prompts/instructions.md` ("interfaces must be inferred from Zod") and
+ spec's own `src/contracts/schema-driver.ts`.
+ - `ManifestSchema` becomes `ObjectStackDefinitionSchema` from the package root:
+ the prompt's subject is `objectstack.config.ts`, which is neither of the
+ `/system` manifests.
+ - `IdentitySchema` / `PolicySchema` have no bare referent; Rule #2 now names
+ `RLSUserContextSchema` and `RowLevelSecurityPolicySchema` from
+ `@objectstack/spec/security`.
+ - The three non-existent "Key Files to Watch" paths
+ (`system/{manifest,identity,events}.zod.ts`) now point at `stack.zod.ts`,
+ `security/rls.zod.ts` and `kernel/events.zod.ts`.
+- 8bee54b: docs(spec): the `driver-sql-unresolvable-where-column-refused` ledger entry states MySQL's reach as it is after #8926, not as it was at registration (#9060)
+
+ Text amendment to an already-registered ADR-0087 entry — the entry id, `surface`
+ and `replacement` prescription are unchanged, and no accept/reject behaviour
+ moves. What changes is the `reason`, which is upgrader-facing documentation: it
+ is the data source for `objectstack migrate meta`, `spec-changes.json` and the
+ generated upgrade guide.
+
+ The entry's "Reach, stated rather than assumed" paragraph said MySQL was outside
+ the refusal — true when #8790 registered it, false the moment #8926 merged (PR
+ #9061). A MySQL user reading "on MySQL this condition still travels out as the
+ raw dialect error" would have concluded the migration did not apply to them,
+ which is exactly wrong after parity.
+
+ The historical paragraph is kept verbatim as the state at registration, and a
+ dated addendum states both halves of what the one shared predicate did on MySQL:
+
+ - **The envelope** — an unresolvable WHERE column refuses with the same
+ `INVALID_FILTER` / 400 naming the column, instead of the raw
+ `ER_BAD_FIELD_ERROR` with the statement's bound literals inlined.
+ - **The recoveries** — MySQL also gained the #3821 projection and ORDER-BY
+ recoveries it never had, so those positions now return recovered rows where
+ they used to throw.
+
+ Both arrive together because `ER_BAD_FIELD_ERROR` spells every clause position
+ with one sentence, so all three ride one arm of the predicate — pinned as the
+ ruled direction by the widened sweep in
+ `sql-driver-unresolvable-where-column-refusal.test.ts`. Unchanged by that
+ ruling, and said so in the addendum: a dotted filter key is still classified per
+ dialect, the axis #8371 owns.
+- ff08691: fix(engine-core): a system-context insert on a tenant-scoped object resolves the install's organization the way a session write does, or is refused — the runtime producer of the autonumber fork #8686's backfill cannot reach (#8844)
+
+
+
+ #8686 fixed **one** producer of untenanted rows — the seed loader — and shipped
+ a one-shot backfill for what it had already written. This card is the **other
+ producer, which is still running**: an ordinary application write made under a
+ system execution context (a hook, a scheduled job, a custom endpoint, a
+ `runAs: system` flow). A backfill cannot reach it, because it mints a fresh
+ duplicate on every tick — which makes #8686's repair **self-undoing on any
+ install with server-side automation**, i.e. every business app.
+
+ **Measured on 17.0.0 GA**, a single-tenant EHR/MES install with ~44 autonumbered
+ objects: two records, same object, same install, the **same** value on a field
+ the app declared `unique`, with no error and no warning. The `notification` case
+ shows both producers side by side — `NT-00002 .. NT-00011` each existing twice,
+ copy A written by the "maintenance overdue" cron job, copy B by a user action.
+
+ **Mechanism.** A session write carries the caller's active organization, the SQL
+ driver stamps it onto the row (`injectTenantOnInsert`), and the autonumber
+ counter reads it back off the row (`fillAutoNumberFields`, resolving
+ `row[tenantField] ?? options.tenantId ?? null`). A system-context write carries
+ none, so the column lands `NULL` and the counter files the row under the
+ `__global__` pseudo-tenant. One object then runs two counters that cannot see
+ each other, each correct within its own scope, and the partitioned unique index
+ — `(COALESCE(organization_id, '__global__'), )`, ADR-0120 D3 — cannot see
+ across the two partitions either.
+
+ ⛔ **Not a counter bug**, and not fixed by making the allocator smarter: both
+ counters are already correct within their own scopes (the reasoning #8686
+ recorded, unchanged). The defect is upstream of the counter.
+
+ **The fix, per the 2026-08-15 maintainer ruling (Option 1)** — a system-context
+ write resolves the install's organization the way a session write does, at the
+ engine's stamp resolution, so every driver is covered at the source (which
+ matters here because `fillAutoNumberFields` is duplicated in `driver-sql` and
+ `driver-turso`; neither driver changed):
+
+ - **Single-tenant, exactly one organization ⇒ derive and stamp.** The
+ `__global__` fork stops being minted by hooks, cron and system endpoints.
+ - **Multi-organization ⇒ carry an explicit organization or be REFUSED LOUDLY**,
+ never silently defaulted. A walled posture (`group` / `isolated`), or a
+ `single` posture whose data holds several organizations, has no derivable
+ answer — the refusal is `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (500,
+ registered in the ADR-0112 ledger), thrown before anything reaches the driver,
+ and its message names the condition, what would otherwise have been written,
+ and both remedies.
+ - **Already-minted duplicates are reported, never rewritten** — the #8686
+ posture, ruled again here. Nothing in this change renumbers anything.
+
+ **Three populations are outside the rule by construction, not by exemption**, so
+ that the refusal cannot break unattended automation that was never at risk:
+ objects with no organization column, objects declaring `tenancy: { enabled:
+ false }` (ADR-0066 — the *declared* way to hold org-less rows, rather than a
+ per-write bypass flag) and federated objects (ADR-0015); the platform namespaces
+ `sys_` / `cloud_` / `ai_`, whose rows are deliberately global (#8672's reasoning,
+ which this ruling confirms holds for platform objects and does **not** generalize
+ to application objects); and any write that already carries an organization — on
+ the execution context, on the record, or stamped by a `beforeInsert` hook.
+
+ **First boot is untouched:** before any organization exists there is nothing to
+ derive and no second partition to fork away from, so those rows still land
+ org-less for #8686's `sys_organization`-insert handoff to adopt.
+
+ Scoped to **insert**, deliberately: the ruling's yardstick is "the way a session
+ write does", and stamping the organization is an insert-side mechanism — an
+ update neither stamps it nor can fork a counter.
+- 56bca91: docs(spec): `TenantPlanSchema` doc block states the entitlement-layer fold, not normalization (#9345)
+
+ `TenantPlanSchema`'s doc block claimed that an unrecognized plan code is folded
+ to the free tier by "the cloud distribution's normalization." That was
+ measured wrong on two counts as of the cloud#1380 ruling (2026-08-16, landed
+ in cloud PR #1417, merged 2026-08-17):
+
+ - The fold happens at the **entitlement layer** (e.g. `isFreePlan`), never in
+ normalization — `sys_environment.plan` keeps the raw value (case-normalized
+ only), so an unrecognized tier stays distinguishable from the free tier to
+ any reader, log line, or operator. Writing it as normalization is exactly
+ what cloud#1389's red line forbids: normalize the spelling, never the
+ vocabulary.
+ - Before the ruling landed, only the control-plane `planKey` reader folded
+ unknown codes to free; the tenant-runtime `isFreePlan` reader granted paid
+ access to an unrecognized code. As of cloud PR #1417 both mirrors fold.
+
+ The corrected doc block also states, explicitly, what it must not say: the two
+ mirrors' vocabularies are not merged into one list (cloud#1380 lands a
+ pinned *copy*; unifying them is cloud#1418, ruled but not yet landed, and a
+ SHA-pinned image can predate a vocabulary entry even after that lands), and
+ it carries the ruling's operational premise (new plan tiers are minted
+ rarely, images roll before a new tier goes on sale) so the spec text does not
+ contradict cloud's `isFreePlan` docstring, which states the same premise.
+
+ Doc-block prose only — `TenantPlanSchema` still accepts any string and
+ enforces no vocabulary; acceptance behavior is unchanged.
+- 44bc51d: refactor(spec): the union-branch selection policy has ONE implementation, and a parity test that keeps it that way (#8318)
+
+ `shared/error-map.zod.ts` (the prose renderer, #4971/#5389) and
+ `api/zod-issues-to-fields.ts` (the ADR-0114 D3 wire mapper, #8124) carried the
+ SAME union-branch selection policy as two separate implementations —
+ kind-mismatch drop, fewest-issues ranking, `unrecognized_keys` tie-break,
+ declaration-order determinism, depth limit 3, branch cap 3, and the
+ `invalid_key` / `invalid_element` container codes. While the mapper still lived
+ in `@objectstack/rest` the duplication was forced; #8124 moved it into this
+ package, so the two sat one directory apart with their module headers — and
+ nothing mechanical — asking whoever edits one to edit the other.
+
+ The policy now lives in one package-internal module,
+ `src/shared/union-branch-policy.ts`, which both walks import. It is deliberately
+ NOT a public export: it is absent from every barrel, and `api-surface/` and
+ `export-origins/` do not move.
+
+ The two WALKS stay separate implementations, as they should — one renders
+ indented `✗ path: message` prose for a terminal, the other produces
+ `{field, code, message}` entries for a JSON envelope, and only the renderer
+ emits the trailing "… and N more branches rejected this value" line. That
+ asymmetry is now explicit rather than implicit: `selectUnionBranches` returns
+ `{selected, omitted}`, the renderer prints `omitted`, and the mapper
+ destructures `selected` alone at a commented line, because a `fields[]` entry
+ must name a real field and carry a catalog code and an omission count has
+ neither.
+
+ `src/shared/union-branch-policy.parity.test.ts` is the enforcement the module
+ headers lacked: one `safeParse` per fixture feeds BOTH walks, and their outputs
+ are compared pair for pair after a normalisation that removes the indent, the
+ `✗` glyph and the `(root)` spelling — nothing else. The corpus covers every rule
+ of the policy (kind-mismatch drop, all-kind-mismatch, fewest-issues ranking, the
+ `unrecognized_keys` tie-break, declaration-order determinism, the depth limit,
+ the branch cap, and container descent for both `invalid_key` and
+ `invalid_element`), and the one deliberate asymmetry is asserted rather than
+ normalised away.
+
+ Behaviour is unchanged for every issue zod produces: the ranking, both limits
+ and the container-code set are byte-identical to what each walk applied before.
+ The single deliberate widening is that the shared policy reads a missing or
+ non-array `path` as the root — the wire mapper's already-shipped normalisation,
+ now applied to the renderer too, which previously threw on such an issue object.
+ No value satisfying the renderer's own `ZodIssueMinimal` type is affected.
+
## 17.0.0
### Major Changes
diff --git a/packages/spec/package.json b/packages/spec/package.json
index 3d3cb5f69a..5624106d3d 100644
--- a/packages/spec/package.json
+++ b/packages/spec/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/spec",
- "version": "17.0.0",
+ "version": "17.1.0",
"description": "ObjectStack Protocol & Specification - TypeScript Interfaces, JSON Schemas, and Convention Configurations",
"license": "Apache-2.0",
"main": "dist/index.js",
diff --git a/packages/triggers/trigger-api/CHANGELOG.md b/packages/triggers/trigger-api/CHANGELOG.md
index 128a55faed..761adb52e6 100644
--- a/packages/triggers/trigger-api/CHANGELOG.md
+++ b/packages/triggers/trigger-api/CHANGELOG.md
@@ -1,5 +1,96 @@
# @objectstack/trigger-api
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/triggers/trigger-api/package.json b/packages/triggers/trigger-api/package.json
index 00260f0286..036da662ee 100644
--- a/packages/triggers/trigger-api/package.json
+++ b/packages/triggers/trigger-api/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/trigger-api",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Inbound HTTP/webhook flow trigger for ObjectStack — per-flow HMAC-verified endpoints with queue-backed ingestion (ADR-0041)",
"main": "dist/index.js",
diff --git a/packages/triggers/trigger-record-change/CHANGELOG.md b/packages/triggers/trigger-record-change/CHANGELOG.md
index aa845204dc..b58c4bf8d9 100644
--- a/packages/triggers/trigger-record-change/CHANGELOG.md
+++ b/packages/triggers/trigger-record-change/CHANGELOG.md
@@ -1,5 +1,96 @@
# @objectstack/plugin-trigger-record-change
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json
index 1544bd3aac..4739dccf47 100644
--- a/packages/triggers/trigger-record-change/package.json
+++ b/packages/triggers/trigger-record-change/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/trigger-record-change",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Record-change flow trigger for ObjectStack — auto-launches flows on object insert/update/delete via ObjectQL lifecycle hooks (ADR-0018)",
"main": "dist/index.js",
diff --git a/packages/triggers/trigger-schedule/CHANGELOG.md b/packages/triggers/trigger-schedule/CHANGELOG.md
index 02fa8e5935..ef443d8a92 100644
--- a/packages/triggers/trigger-schedule/CHANGELOG.md
+++ b/packages/triggers/trigger-schedule/CHANGELOG.md
@@ -1,5 +1,96 @@
# @objectstack/plugin-trigger-schedule
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+ - @objectstack/core@17.1.0
+
## 17.0.0
### Patch Changes
diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json
index 69043ca47d..d2058cd3bd 100644
--- a/packages/triggers/trigger-schedule/package.json
+++ b/packages/triggers/trigger-schedule/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/trigger-schedule",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Schedule flow trigger for ObjectStack — auto-launches flows on a cron/interval/once schedule via the IJobService (ADR-0018)",
"main": "dist/index.js",
diff --git a/packages/types/CHANGELOG.md b/packages/types/CHANGELOG.md
index a6ae93a20b..e15ec7f77e 100644
--- a/packages/types/CHANGELOG.md
+++ b/packages/types/CHANGELOG.md
@@ -1,5 +1,305 @@
# @objectstack/types
+## 17.1.0
+
+### Minor Changes
+
+- 2f65b1b: `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`".
+
+### Patch Changes
+
+- 2d0af57: 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).
+- 27a567d: fix(types): teach the internal-leak predicate MySQL's three error templates (#8739)
+
+ `looksLikeInternalErrorLeak` decides whether a message is a driver dump that
+ must not reach an API client. It is applied at three HTTP boundaries
+ (`@objectstack/rest`'s `mapDataError`, `@objectstack/runtime`'s
+ dispatcher-plugin and endpoint-executor, the hono adapter) and by
+ `@objectstack/objectql`'s log redactor. Its dialect list covered the SQLite
+ family and Postgres; on a MySQL deployment it returned `false` for every one of
+ these conditions — **silent, not clearing**.
+
+ Under the maintainer's 2026-08-15 ruling on #8739, **MySQL is a supported
+ deployment target**, not merely a tested dialect — the answer already implied by
+ what is published (`OS_DATABASE_DRIVER=mysql` as a documented deployment knob,
+ `MysqlConfig` as authorable datasource config, per-field MySQL DDL in
+ `types.mdx`) and by a required CI check that stands up a live `mysql:8.0`. A
+ supported target's driver text reaches those boundaries in production, so its
+ templates belong in the list.
+
+ **Now recognised** — one per condition the other two dialects were already
+ covered for, each anchored on MySQL's own errmsg template rather than on a bare
+ substring:
+
+ - `Table 'app.t' doesn't exist` (ER_NO_SUCH_TABLE 1146). MySQL's contracted
+ spelling quotes `db.table` as one identifier, so the Postgres
+ `relation "t" does not exist` limb could never reach it.
+ - `Unknown column 'c' in 'field list'` (ER_BAD_FIELD_ERROR 1054). Both quoted
+ parts are required; the second is MySQL's clause name (`field list`,
+ `where clause`, `order clause`, `on clause`), and it is what distinguishes the
+ driver's template from a sentence that merely calls a column unknown.
+ - `Duplicate entry 'x' for key 'i'` (ER_DUP_ENTRY 1062). The `for key` tail plus
+ a quoted index is the anchor. This is the one MySQL template whose text embeds
+ a **caller's value** rather than an identifier — SQLite's
+ `UNIQUE constraint failed: t.c` and Postgres' `violates unique constraint "…"`
+ both name only an index — which is why closing this gap was worth a behaviour
+ change rather than another comment.
+
+ **Deliberately still NOT recognised**, so the boundary of the change is on the
+ record rather than inferred:
+
+ - **MySQL's ACL family** — `Access denied for user 'u'@'h' to database 'd'`
+ (1044), `SELECT command denied to user … for table 't'` (1142) — the
+ counterpart of the Postgres `permission denied for table` limb. Nothing in
+ this repo has raised one off a live server, and the standing rule in this
+ neighbourhood (`unique-violation.ts`) is that a dialect's spelling is added
+ once it has been MEASURED off a thrown error, never from a reading of the
+ manual. `Access denied` also collides with this platform's own security prose
+ (`[Security] Access denied: …`), so a guessed pattern here would over-match —
+ and over-matching suppresses diagnostics an operator needs.
+ - **MSSQL and Oracle** — `Invalid object name 'sys_metadata'.`,
+ `ORA-00942: table or view does not exist` still return `false`.
+ - **Prose that shares the keywords without the driver's anchoring** — an import
+ summary saying `duplicate entry in the uploaded file`, a mapping message
+ saying `Unknown column in the uploaded CSV header`, `The table you selected
+ does not exist`. Pinned as negative cases, because a phrasing list that says
+ "leak" too often replaces real answers with `Internal server error`.
+
+ **The `false`-means-UNCOVERED rule survives the change and keeps a live
+ subject.** A `false` here has never meant the text is safe, only that the
+ predicate never learned that dialect — the reading a reviewer on PR #8737 got
+ wrong while sizing a disclosure residual, which is what produced this card. The
+ four `toBe(false)` pins PR #8824 planted as a tripwire for this exact moment
+ went red as designed and are rewritten, not deleted: the same three measured
+ messages now assert `true`, so a future change that silently drops MySQL
+ coverage fails there, and a second block keeps the original `false`-means-
+ uncovered shape pointed at MSSQL and Oracle. `declaresServerFault` remains the
+ phrasing-independent answer.
+
+ **No status mapping moves.** `@objectstack/rest` answers the 409 conflict
+ question with `isUniqueViolationError`, above and independently of this
+ predicate (#6250), so a MySQL duplicate-entry error is still `409
+ UNIQUE_VIOLATION` and a MySQL unknown-column error is still `400 INVALID_FIELD`
+ — both decided before the leak branch is reached. The log redactor is unchanged
+ too: a bare MySQL diagnostic carries no knex ` - ` separator, so there is no
+ statement to cut. Measured across the predicate's full consumer set — types,
+ objectql, rest, runtime, metadata-protocol, hono, service-package,
+ service-analytics — the only verdicts that moved are the two that measure this
+ predicate directly.
+
+ No live MySQL deployment leaking through these boundaries was measured; this
+ closes a gap in what the boundary recognises, and the card is explicit that no
+ leak was demonstrated.
+- bbbfcfc: fix(types): `isUniqueViolationError` stops claiming the sentences that say a unique constraint is ABSENT (#8590)
+
+ The shared predicate's message limb was a bare `unique constraint`, and a word
+ pair is not a condition. Every dialect that can say "this row violated a unique
+ constraint" can also say "there is no unique constraint here", and the same two
+ words sit adjacent in both — so the predicate answered **true** for errors
+ meaning the exact opposite of what it detects. `rest-server.ts` maps that
+ verdict to `409 UNIQUE_VIOLATION`, which tells a client to change a value when
+ nothing was ever compared, on a status an SDK will not retry.
+
+ **Measured on live servers for this fix, all three supported dialect families**
+ — SQLite via better-sqlite3, PostgreSQL 16.13 via `pg` 8.22.0, MariaDB 10.11.14
+ via `mysql2` 3.23.1, all through knex 3.3.0 — driving each dialect through both
+ conditions plus the NOT NULL / FOREIGN KEY near misses:
+
+ ```
+ sqlite ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint
+ -> was true, WRONG (the reported defect, #8590)
+ postgres there is no unique constraint matching given keys for referenced table "t"
+ -> was true, WRONG (42830 — found by this fix's dialect sweep)
+ postgres there is no unique or exclusion constraint matching the ON CONFLICT specification
+ -> false (the pair is not adjacent here)
+ mysql the condition cannot arise: knex compiles to ON DUPLICATE KEY UPDATE,
+ which carries no conflict target (confirmed against a live server)
+ ```
+
+ **Postgres was not clean either, and that chose the fix.** #8590 was filed
+ reading the collision as SQLite-only, with Postgres escaping "by luck of word
+ order". The sweep raised **42830** — a `FOREIGN KEY` referencing a non-unique
+ column — where Postgres puts `unique constraint` adjacent in its own absence
+ sentence. The card offered two candidate fixes; only one survives 42830. A
+ negative lookahead on SQLite's missing-index sentence is a blocklist that can
+ only enumerate absence sentences somebody already tripped over, and it answers
+ `true` on 42830. So the limb now requires a **violation phrasing** —
+ `unique constraint failed` (SQLite) or `violates unique constraint` (Postgres) —
+ which restores the module's own stated default, *unrecognised is `false`*, to
+ the message channel.
+
+ **Both spellings the retired limb covered are preserved exactly**, which was the
+ constraint on the fix: the limb was inherited verbatim from the REST branch
+ #6250 replaced and covered SQLite's `UNIQUE constraint failed: t.c` *and*
+ Postgres' `... violates unique constraint "..."`. The `unique violation`,
+ `duplicate key` and `duplicate entry` limbs are untouched, as are the `code` and
+ `errno` channels — MySQL's `Duplicate entry` path never went through the
+ narrowed limb at all.
+
+ **No user-visible behaviour changes today; this closes a latent inversion.** The
+ one site compiling a caller-supplied conflict target (`SqlDriver.upsert`)
+ recognises the unbacked target *first* in its catch and throws a refusal
+ declaring `status: 400`, and `mapDataError` reads `declaredHttpStatus` before it
+ reaches the unique-violation branch — so the 409 was gated off the wire by
+ ordering, not by the verdict. That ordering was the only thing standing between
+ this and a wrong status, which is why the verdict is now pinned rather than left
+ to it. A repo-wide scan of every string literal whose verdict moves found no
+ consumer relying on the old answer: all of them are prose, a different
+ predicate's vocabulary (`looksLikeInternalErrorLeak` keeps its own list), or
+ fixtures asserted through the status-passthrough path.
+
+ `unbacked-conflict-target.test.ts`'s pin — written by #8567 to point at itself
+ rather than go quietly green — is **inverted, not deleted**, and
+ `unique-violation-absence-sentences.test.ts` pins the absence sentences per
+ dialect in both directions, including the code channel, so re-reading `code`
+ cannot undo the message-side fix from the other side.
+- Updated dependencies [56656aa]
+- Updated dependencies [07e630e]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [9aa8890]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [75b7c24]
+- Updated dependencies [d9813a9]
+- Updated dependencies [8640fb2]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [420804d]
+- Updated dependencies [716ac9b]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [2b292ce]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [19539b4]
+- Updated dependencies [11b779e]
+- Updated dependencies [739fe5b]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [2065e31]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4d47afe]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [e2899f6]
+- Updated dependencies [3851f87]
+- Updated dependencies [2a29caa]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [cd455c8]
+- Updated dependencies [30d3752]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [30b1c63]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [7901b2d]
+- Updated dependencies [56bca91]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [44bc51d]
+- Updated dependencies [d634e66]
+ - @objectstack/spec@17.1.0
+
## 17.0.0
### Minor Changes
diff --git a/packages/types/package.json b/packages/types/package.json
index cfbc837ded..b3164bb367 100644
--- a/packages/types/package.json
+++ b/packages/types/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/types",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Shared interfaces describing the ObjectStack Runtime environment",
"main": "dist/index.js",
diff --git a/packages/verify/CHANGELOG.md b/packages/verify/CHANGELOG.md
index 73e0c9218b..d1706be44e 100644
--- a/packages/verify/CHANGELOG.md
+++ b/packages/verify/CHANGELOG.md
@@ -1,5 +1,221 @@
# @objectstack/verify
+## 17.1.0
+
+### Patch Changes
+
+- Updated dependencies [56656aa]
+- Updated dependencies [c9f5950]
+- Updated dependencies [d6e80b2]
+- Updated dependencies [07e630e]
+- Updated dependencies [66beee0]
+- Updated dependencies [2f65b1b]
+- Updated dependencies [ca2e020]
+- Updated dependencies [720ee95]
+- Updated dependencies [f287435]
+- Updated dependencies [e7bccaa]
+- Updated dependencies [2782805]
+- Updated dependencies [e43d63a]
+- Updated dependencies [e374b4d]
+- Updated dependencies [5047cb8]
+- Updated dependencies [ed4ca59]
+- Updated dependencies [445ae4d]
+- Updated dependencies [5aadce3]
+- Updated dependencies [bcf2755]
+- Updated dependencies [a433122]
+- Updated dependencies [bc6434b]
+- Updated dependencies [96f397a]
+- Updated dependencies [9aa8890]
+- Updated dependencies [48032c9]
+- Updated dependencies [7c9c1dd]
+- Updated dependencies [03520eb]
+- Updated dependencies [2277443]
+- Updated dependencies [a751f7d]
+- Updated dependencies [eccb8b2]
+- Updated dependencies [650cd3d]
+- Updated dependencies [b735507]
+- Updated dependencies [91c6c28]
+- Updated dependencies [75b7c24]
+- Updated dependencies [cf0d902]
+- Updated dependencies [498f4e8]
+- Updated dependencies [cc5c07b]
+- Updated dependencies [d9813a9]
+- Updated dependencies [4c178c1]
+- Updated dependencies [8640fb2]
+- Updated dependencies [5c38492]
+- Updated dependencies [2420641]
+- Updated dependencies [2ad91c3]
+- Updated dependencies [f57fb38]
+- Updated dependencies [3508678]
+- Updated dependencies [00777a0]
+- Updated dependencies [d491625]
+- Updated dependencies [04d03c3]
+- Updated dependencies [6a51704]
+- Updated dependencies [2d0af57]
+- Updated dependencies [7337f30]
+- Updated dependencies [420804d]
+- Updated dependencies [8656d67]
+- Updated dependencies [51a46a4]
+- Updated dependencies [c8e85fc]
+- Updated dependencies [3d61924]
+- Updated dependencies [5244fd7]
+- Updated dependencies [716ac9b]
+- Updated dependencies [e9534a4]
+- Updated dependencies [6feac91]
+- Updated dependencies [62b1427]
+- Updated dependencies [7ea1372]
+- Updated dependencies [23abe27]
+- Updated dependencies [985a9cd]
+- Updated dependencies [b2789ad]
+- Updated dependencies [5f5e234]
+- Updated dependencies [a8189ae]
+- Updated dependencies [26e70fb]
+- Updated dependencies [27a567d]
+- Updated dependencies [4ea921c]
+- Updated dependencies [0ccea4a]
+- Updated dependencies [3ab2488]
+- Updated dependencies [2b292ce]
+- Updated dependencies [185c7bd]
+- Updated dependencies [abcf853]
+- Updated dependencies [8b9eba5]
+- Updated dependencies [d575779]
+- Updated dependencies [c5ac5e4]
+- Updated dependencies [a777944]
+- Updated dependencies [66dbec4]
+- Updated dependencies [6aceca9]
+- Updated dependencies [dd88e1c]
+- Updated dependencies [856527c]
+- Updated dependencies [870f710]
+- Updated dependencies [45862a5]
+- Updated dependencies [152bff8]
+- Updated dependencies [7ff3975]
+- Updated dependencies [29d055b]
+- Updated dependencies [65589d6]
+- Updated dependencies [2c86fe3]
+- Updated dependencies [e196c6a]
+- Updated dependencies [24173e9]
+- Updated dependencies [19539b4]
+- Updated dependencies [b705a6c]
+- Updated dependencies [f8eb736]
+- Updated dependencies [11b779e]
+- Updated dependencies [4e71ae1]
+- Updated dependencies [739fe5b]
+- Updated dependencies [20067c5]
+- Updated dependencies [d09d0fd]
+- Updated dependencies [5ed8ee6]
+- Updated dependencies [e783e16]
+- Updated dependencies [4bfe1a5]
+- Updated dependencies [b537855]
+- Updated dependencies [2065e31]
+- Updated dependencies [6cb88d9]
+- Updated dependencies [b69d0f5]
+- Updated dependencies [4dc8a61]
+- Updated dependencies [4d47afe]
+- Updated dependencies [4fc4a3c]
+- Updated dependencies [90a12fb]
+- Updated dependencies [e4e5c6e]
+- Updated dependencies [72050cc]
+- Updated dependencies [d70428a]
+- Updated dependencies [4dfa369]
+- Updated dependencies [9a56784]
+- Updated dependencies [d00d2f6]
+- Updated dependencies [df0c12d]
+- Updated dependencies [d31785f]
+- Updated dependencies [c308a4f]
+- Updated dependencies [5e2f594]
+- Updated dependencies [e2899f6]
+- Updated dependencies [7ff5aa2]
+- Updated dependencies [b6c7690]
+- Updated dependencies [855591f]
+- Updated dependencies [e6e1de4]
+- Updated dependencies [6a12e5e]
+- Updated dependencies [3851f87]
+- Updated dependencies [c73eacd]
+- Updated dependencies [f8537df]
+- Updated dependencies [712e185]
+- Updated dependencies [88e1bac]
+- Updated dependencies [693c788]
+- Updated dependencies [0961065]
+- Updated dependencies [2a29caa]
+- Updated dependencies [9e2e682]
+- Updated dependencies [09a6eee]
+- Updated dependencies [1a7f907]
+- Updated dependencies [0425db9]
+- Updated dependencies [cd455c8]
+- Updated dependencies [e1bb0ca]
+- Updated dependencies [05864fb]
+- Updated dependencies [326f5de]
+- Updated dependencies [501ed0e]
+- Updated dependencies [f047810]
+- Updated dependencies [30d3752]
+- Updated dependencies [21995d7]
+- Updated dependencies [c80e7ae]
+- Updated dependencies [499f55e]
+- Updated dependencies [09a9a8a]
+- Updated dependencies [07026cf]
+- Updated dependencies [5d4f3d5]
+- Updated dependencies [4d80e8b]
+- Updated dependencies [6a5e6ad]
+- Updated dependencies [30b1c63]
+- Updated dependencies [7fc01db]
+- Updated dependencies [079b457]
+- Updated dependencies [e43b211]
+- Updated dependencies [c86799f]
+- Updated dependencies [b030055]
+- Updated dependencies [5989b0d]
+- Updated dependencies [19db5fa]
+- Updated dependencies [2b9d33a]
+- Updated dependencies [ad217b1]
+- Updated dependencies [f01c0ee]
+- Updated dependencies [fab693b]
+- Updated dependencies [b53d38e]
+- Updated dependencies [890b38f]
+- Updated dependencies [8bee54b]
+- Updated dependencies [04f8fdb]
+- Updated dependencies [7a537ce]
+- Updated dependencies [593c4bf]
+- Updated dependencies [b2a451f]
+- Updated dependencies [c25b2d5]
+- Updated dependencies [6158146]
+- Updated dependencies [84cb121]
+- Updated dependencies [ca19ee8]
+- Updated dependencies [147eadc]
+- Updated dependencies [a675b4d]
+- Updated dependencies [b887013]
+- Updated dependencies [ff08691]
+- Updated dependencies [90c5285]
+- Updated dependencies [402c125]
+- Updated dependencies [7901b2d]
+- Updated dependencies [7c2f386]
+- Updated dependencies [56bca91]
+- Updated dependencies [b3f9831]
+- Updated dependencies [79394d7]
+- Updated dependencies [730fd9a]
+- Updated dependencies [8a9e7f4]
+- Updated dependencies [3d0ded8]
+- Updated dependencies [44bc51d]
+- Updated dependencies [bbbfcfc]
+- Updated dependencies [1258dca]
+- Updated dependencies [91c4ff5]
+- Updated dependencies [d634e66]
+- Updated dependencies [682b86b]
+- Updated dependencies [6a1b45e]
+ - @objectstack/spec@17.1.0
+ - @objectstack/platform-objects@17.1.0
+ - @objectstack/plugin-auth@17.1.0
+ - @objectstack/types@17.1.0
+ - @objectstack/runtime@17.1.0
+ - @objectstack/plugin-security@17.1.0
+ - @objectstack/rest@17.1.0
+ - @objectstack/core@17.1.0
+ - @objectstack/objectql@17.1.0
+ - @objectstack/service-automation@17.1.0
+ - @objectstack/service-datasource@17.1.0
+ - @objectstack/plugin-sharing@17.1.0
+ - @objectstack/plugin-hono-server@17.1.0
+ - @objectstack/service-settings@17.1.0
+ - @objectstack/service-analytics@17.1.0
+
## 17.0.0
### Major Changes
diff --git a/packages/verify/package.json b/packages/verify/package.json
index 8105458a42..ebec2774c6 100644
--- a/packages/verify/package.json
+++ b/packages/verify/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/verify",
- "version": "17.0.0",
+ "version": "17.1.0",
"license": "Apache-2.0",
"description": "Boot any ObjectStack app in-process and verify it through the real HTTP stack — auto-derived CRUD round-trip fidelity plus the cross-owner RLS invariant. Catches runtime regressions that static checks miss.",
"type": "module",