From 1c358532d33e24070741ba0afc4287b4edbe35b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 06:30:02 +0000 Subject: [PATCH 1/5] docs(releases): split the v17 page per minor, and draft the missing 17.4.0 section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `content/docs/releases/v17.mdx` had reached 5,611 lines / 369 KB — 5.7x the finished v16 page, at 17.3.0, with 17.4.0 still to land. At roughly 90k tokens it is two thirds of the budget this project advertises for an entire application, in one release-notes file. It is now a folder, one self-contained page per minor: v17/index.mdx major landing: intro, release status, the cross-release upgrade-checklist guidance, References v17/17.0.mdx 1,917 highlights + detail + its own upgrade checklist v17/17.0-prerelease.mdx 1,688 archive: the rc.0 … rc.6 train, 30% of the old page and read by nobody upgrading between shipped versions v17/17.1 .. 17.3.mdx the same shape, 375-783 lines each v17/17.4.mdx NEW — see below A reader's question is per-minor, and the old page answered it in three places 1,200 lines apart: "What's new in 17.2.0", "Breaking changes & migration in 17.2.0", and "17.2.0" under a separate "Upgrade checklist" heading. Each minor page now carries all three. Content is conserved: every line of the old body appears in exactly one new file, verified by set difference. The `### 17.x.0` checklist headings are kept verbatim, so their `#1730`-style anchors survive the move. 17.4.0 was published on 2026-09-09 — the releases index already named it the current series — and had no section on any page. `v17/17.4.mdx` is a MACHINE DRAFT and says so at the top: 396 changesets de-duplicated by hash across all 61 package changelogs (a changeset that bumps six packages appears once), split into 35 carrying a breaking marker, 140 other minor entries, and 221 patch entries reported as counts rather than enumerated. No editorial pass has been made, the Highlights section is deliberately absent rather than guessed, and the upgrade checklist is not written because nobody has walked 17.3.0 → 17.4.0. Deciding what is user-facing is the maintainer's judgement at release time. Two gates learn the second layout rather than being weakened. `check-release-notes` (page existence) and `check-release-page-status` (what the page says) now accept either `v.mdx` or `v/index.mdx`; neither layout is deprecated and no baseline or exception list is introduced. Both pass, as does `check-release-section-coverage`, which skips v17 and names its own reason. `role-word-baseline.json` records a pure relocation: the single row `content/docs/releases/v17.mdx: 2` becomes `v17/17.0.mdx: 2`. Nothing else in the baseline moved — the sum is unchanged and no other file was re-baselined in the same stroke. One reserved word in a changeset's own headline is elided in 17.4.mdx with the elision stated inline, rather than silently rewritten. `upgrading.mdx` follows the checklist links to their new pages and gains a 17.4.0 row that says plainly that its checklist is not written. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016M1gHPfToXY4sUdzsD2CHa --- content/docs/releases/v17.mdx | 5611 ----------------- content/docs/releases/v17/17.0-prerelease.mdx | 1688 +++++ content/docs/releases/v17/17.0.mdx | 1917 ++++++ content/docs/releases/v17/17.1.mdx | 720 +++ content/docs/releases/v17/17.2.mdx | 377 ++ content/docs/releases/v17/17.3.mdx | 783 +++ content/docs/releases/v17/17.4.mdx | 429 ++ content/docs/releases/v17/index.mdx | 203 + content/docs/releases/v17/meta.json | 12 + content/docs/upgrading.mdx | 9 +- scripts/check-release-notes.mjs | 10 +- scripts/check-release-page-status.mjs | 14 +- scripts/role-word-baseline.json | 2 +- 13 files changed, 6154 insertions(+), 5621 deletions(-) delete mode 100644 content/docs/releases/v17.mdx create mode 100644 content/docs/releases/v17/17.0-prerelease.mdx create mode 100644 content/docs/releases/v17/17.0.mdx create mode 100644 content/docs/releases/v17/17.1.mdx create mode 100644 content/docs/releases/v17/17.2.mdx create mode 100644 content/docs/releases/v17/17.3.mdx create mode 100644 content/docs/releases/v17/17.4.mdx create mode 100644 content/docs/releases/v17/index.mdx create mode 100644 content/docs/releases/v17/meta.json diff --git a/content/docs/releases/v17.mdx b/content/docs/releases/v17.mdx deleted file mode 100644 index 6ca17f4ba7..0000000000 --- a/content/docs/releases/v17.mdx +++ /dev/null @@ -1,5611 +0,0 @@ ---- -title: v17.0.0 -description: Files become platform records with governed download, bulk export becomes its own opt-in privilege, the SDK reaches every route the server actually mounts, approvals route approvers dynamically, and a boot that cannot reach its datasource stops pretending it can. Backend and Console notes for 17.0.0, 17.1.0, 17.2.0, and 17.3.0. ---- - -**The v17 line** is a truth-telling release. Where v16 made *declared metadata* -honest, v17 does the same for the surfaces around it: files stop being inline -blobs and become owned `sys_file` records with a governed download path; the -export privilege stops being a free rider on read; the SDK stops shipping -methods no server ever answered; a datasource that cannot connect stops booting -clean and failing every query afterwards; and an approval request stops being -readable by everyone in the tenant. Alongside that, `agent.tools[]`, the -GraphQL surface, the `ObjectStackProtocol` alias, and a long tail of -parsed-but-never-enforced spec clusters are removed rather than maintained. - -> **Release status: 17.3.0 is released**, and is the current version of the v17 -> line. It was published to the `latest` tag on 2026-09-04, taking over from -> 17.2.0 — published 2026-08-23, which took over from 17.1.0 — published -> 2026-08-20, which took over from 17.0.0 — published 2026-08-14, closing a -> train that ran through `17.0.0-rc.0` … `rc.6` (the last of them cut -> 2026-08-10). A plain install now resolves 17.3.0. `changeset pre -> exit` ran with the 17.0.0 cut, so the `@objectstack/*` packages no longer -> publish as `17.0.0-rc.N`. Caret ranges on `^16.x` hold at 16.x until you opt -> in, which is the reason this train is a major at all: its breaking density -> (the `ApiMethod` shrink, the GraphQL removal, the ADR-0104 write cutover, the -> dead-cluster retirements) is too high to auto-upgrade `^16.x` consumers into -> on their next install. -> -> ⚠️ **17.1.0, 17.2.0 and 17.3.0 are minors by version number, not by blast -> radius. Moving between them is not a tag swap.** Several of 17.1.0's security -> corrections change who can read or write on an existing deployment — read its -> upgrade checklist below. 17.2.0 adds write-path accept-set tightenings of the -> same shape: a by-id `update`/`delete` that used to silently drop an extra -> `where` predicate, or a mismatched `data.id` / `where.id`, now refuses loudly -> instead (#11009, #11142). 17.3.0 goes further still: it renames a published -> SDK namespace with **no aliases** (`client.projects.*` → -> `client.environments.*`), flips the self-registration default to -> `invite_only`, starts enforcing `unique` / `indexes[]` on `driver-memory`, -> makes a permission-store outage fail loudly, and tenant-scopes -> `sys_record_share` with an operator-invoked backfill for the rows written -> before it. Read **[Breaking changes & migration in -> 17.2.0](#breaking-changes--migration-in-1720)** and **[Breaking changes & -> migration in 17.3.0](#breaking-changes--migration-in-1730)** before upgrading. - -## Highlights — 17.0.0 - -- **A file is a record now, not a blob in a column.** Media fields - (`file`/`image`/`avatar`/`video`/`audio`) store an opaque `sys_file` id; - the `{url, name, size, …}` object becomes the *read* (expanded) form. The - platform owns the bytes, so `accept` / `maxSize` are declarable **and - server-enforced**, downloads carry the real filename and content type, - and read authorization can be delegated to the owning object instead of - handed out as an unguessable URL. `os migrate files-to-references` performs - the conversion with a self-check and records a per-deployment flag. -- **Bulk export is its own privilege.** `allowExport` unset used to mean - "inherit read". It now means **denied**. Reading a record and taking a - machine-readable copy of the whole table are different acts — and - `viewAllRecords` / `modifyAllRecords` no longer confer export either. -- **The SDK reaches the surface that exists — and only that surface.** - 21 dead methods (plus the entire phantom `ai` namespace) are deleted, the - four ghost route tables that underwrote them go with them, and 40+ genuinely - mounted routes the SDK could never reach are now typed: actions, keys, - share links, security, package lifecycle, reports, approvals, record shares, - sharing rules, search, `ai.chat`/`ai.chatStream`/`ai.conversations.*`, - `ai.agents.*`, `ai.pendingActions.*`. A route-ledger conformance gate runs - in both directions, so the two sets cannot drift apart again. -- **An approver can choose the next approver.** Approval nodes gain - `expression` approvers (CEL over `current.*` / `trigger.*` / `vars.*`), - a node-level `onEmptyApprovers` policy (`admin_rescue` / `fail` / - `auto_approve`), and declared **decision outputs** that resume the run as - `.` variables — so "the lead reviewer picks which departments - co-review" is a declaration, not a record-field detour. -- **A broken datasource is loud at boot.** A datasource that objects bind to - must connect or the boot fails; `objectql.init()` refuses to start on a dead - data driver; `/ready` answers 503 when one stops answering; and the `default` - datasource is now a declaration travelling the same connect-and-verdict path - as every other one, instead of a second copy of the policy. -- **An approval request is visible to its participants.** `getRequest` / - `listRequests` / `countRequests` applied only the tenant half of the - visibility rule, so any authenticated user could read any request in their - tenant — payload snapshot, decision history, and attachments. -- **A sharing rule with no criteria shares nothing, and a disabled RLS policy - is disabled.** Both were documented contracts whose real behaviour was wider: - a rule stored without criteria evaluated as *every record of the object* - (reachable by a typo through three unvalidated write paths, #3896), and an - RLS policy switched off with `enabled: false` kept contributing its - OR-branch grant. Both now fail closed — found by re-verifying every - security-subset claim in the spec liveness ledger against the actual call - graph, a sweep that also removed the void RLS `priority` knob and the - never-wired plugin sandboxing/integrity config, and left two new CI gates - behind the whole class: a permissive empty state must be classified on - purpose, and the security entries now carry runtime proofs. -- **Node.js 22 is the floor.** `engines.node` said `>=18` across all 50 - manifests while CI, the release pipeline and every shipped Docker image ran - 22. The promise now matches the evidence. -- **The dead-metadata sweep continues.** GraphQL, `PortalSchema`, `AuditConfig`, - the capabilities-descriptor cluster, `FeatureFlagSchema`, `SkillSchema.permissions`, - `tool.requiresConfirmation`, `agent.tools[]`, `object.enable.trash`/`mru`, - report `aria`/`performance`, `DEFAULT_DISPATCHER_ROUTES`, the four inert tool - authoring keys (`category`/`permissions`/`active`/`builtIn`), the close-out - sweep across action/flow/view/dashboard/agent/skill (fourteen more inert - keys, `flow.active` and `agent.knowledge` among them) and the last three - deprecated authorable aliases are removed — each one had been parsed and - ignored. -- **One name, one declaration.** Seventeen clusters of `@objectstack/spec` - exports resolved to *different declarations* depending on which subpath you - imported from — `Session`, `EventSchema`, `RetryPolicy`, `FieldMapping`, - `HttpMethod`, `TenantPlan`, `PackageDependency` and eleven more. An - auto-import picked by name, the shapes overlapped enough to compile, and the - mistake surfaced later as an `undefined` or a silently stripped key. Each - cluster is now judged and resolved — one declaration keeps the bare name, the - other is renamed, re-exported or deleted — and a symbol-identity ratchet - fails the build if a name ever forks again. -- **The authorable surface is closed.** Every authorable metadata type now - rejects unknown keys with a named prescription, on the parse path and not - only in `create()`. The #4001 campaign that started with one object schema - ends this line at zero: object, field, view, dashboard, action, agent, page, - mapping, translation, the six validation variants, the Studio surface and the - registered types behind them. - - -## Highlights — 17.1.0 - -- **Deactivating a permission set or a position now actually stops granting - access.** Both objects shipped a Deactivate action whose dialog promised, in - all four locales, that access stops — and nothing read the column: a position - seeded `active: false` still granted its permission sets, and a permission set - seeded `active: false` still returned `posture: PLATFORM_ADMIN` (#8613). A - sharing rule reached users by a second road that never passed that seam, so a - rule sharing records with a deactivated position kept sharing them (#8710). - Both are closed. ⚠️ **On a deployment that used the switch believing it was - inert, this is a real revocation on live data** — read the upgrade checklist. -- **Platform admin sets stop shipping a bulk-export wildcard.** - `admin_full_access`, `organization_admin` and the derived - `organization_admin_no_bypass` carried `objects['*'].allowExport = true`, which - made the 17.0 export axis undeniable: an application could declare an object - exportable by nobody and the platform exported it anyway (#8681). Export is now - granted per object, in an app's own permission set. -- **Partial field masking.** `FieldSchema` declares `maskingRule` — a closed - preset enum (`phone`, `id_card`, `bank_account`, `email`, `name`) plus a - `{ keepHead, keepTail }` escape hatch — served masked-but-recognisable - (`138****5678`) to every non-system caller, with `requiredPermissions` as the - unmask gate (#8993). -- **`sys_audit_log` can answer "who viewed this record".** The ledger covered - writes only; it gains a `read` action, its writer, and a `record_views` list - view. Record-detail reads only, per-object opt-in, batched off the request - path, and never any field values. -- **All three flow doors answer the same status table.** A refused dispatch stops - reporting a failed run: `404` not found, `409` `FLOW_DISABLED`, `422` - `FLOW_NO_START_NODE`, `400` `FLOW_FAILED` for a run that actually ran and was - rejected. The automation `trigger` routes (#9378 line), the actions door - (#9446) and declared `type: 'flow'` endpoints (#9462) now read one shared - definition instead of three private copies. -- **`error.code` is a closed vocabulary at every door** (#9106). A thrown code - outside `StandardErrorCode ∪ ERROR_CODE_LEDGER` no longer reaches `error.code`; - it rides the new optional `declaredCode` instead, so an app's own spellings - survive without widening the platform vocabulary. -- **Console:** two objectui pin moves (`665661ab0932 → 82a94170c405 → - 9a3daf8d37ad`) bringing dashboard component re-keying, the retirement of the - structured `confirm` object on actions, `I18nLabel` on metric widgets, and - host-performed `submitBehavior.url` redirects for consoles mounted at a - sub-path. - -## Highlights — 17.2.0 - -- **Two write-path guardrails close the "silently dropped predicate" hole.** A - by-id `update`/`delete` whose `where` names anything besides `id` now refuses - loudly instead of binding the row unconditionally and discarding the extra - keys — a compare-and-set written as `{ where: { id, status: {...} } }` used to - land unconditionally with no diagnostic (#11009). A by-id `update` whose - `data.id` and `where.id` are both truthy scalars that **disagree** now refuses - `UPDATE_ID_MISMATCH` (HTTP 400) instead of writing the payload row and - silently dropping the losing id (#11142). Both were unconditional writes - masquerading as conditional ones, not failures — flow `update_record` / - `delete_record` nodes that named `id` plus other filter keys without - declaring `multi: true` are where this is most likely to surface. -- **Further ADR-0049 enforce-or-remove retirements.** `sys_position.permissions` - — a security-object column no producer ever wrote and no runtime path ever - read — is gone (#9885); so are `MetricSchema.filters`, the per-metric raw-SQL - filter nothing read (#10414), the `record:highlights` field `icon` (#10054), - and the `themes` carrier key plus `ThemeSchema` — `app.branding` remains the - one color-authoring surface (#10485). -- **`http_request_errors_total` is retired** (#9834). Its only emitter never - saw the REST data API, the auth mount, or any inbound surface but the - dispatcher's own route Proxy, so the series undercounted from day one. A - dashboard or alert keyed on it now reads a flat zero — that zero is the - removal, not a healthy server. -- **Analytics stops answering the wrong number on a cross-object filter.** A - filter nested inside a combinator (`$or`, `$not`, a nested `$and`) on the - ObjectQL path used to reach `engine.aggregate` unchecked, because the - cross-object envelope check only saw a top-level AND-ed leaf; both analytics - doors now refuse it the same way a top-level cross-object filter already was - (#10759), and a dataset's own definition-level `filter` gets the identical - guard (#10861). -- **Driver introspection stops guessing.** `driver-sql`'s - `introspectPrimaryKeys` / `introspectForeignKeys` / `introspectUniqueConstraints` - used to swallow a failed read and report "no keys" with no diagnostic; a - failed read now throws by default (`{ onFailure: 'partial' }` opts back into - the old behaviour) — schema-drift comparisons and federated-object codegen - were consuming that silent absence as a real answer (#11161). -- **CLI: two dead authoring surfaces are gone.** `os g agent` is retired and - now says why and points at skills — the kernel ships exactly two agents - (`ask`/`build`) per ADR-0063 §2, so scaffolding a third was already discarded - (#10359). The `@capabilities` hook-body directive comment is retired — the - build strips the `//` comment it read before any handler is a runtime - function, so it never reached a build that used it (#10917). -- **A per-item publish naming `?package=` stops matching another package's - draft.** `POST /api/v1/meta/:type/:name/publish?package=PKG_ID` now resolves - its draft's org scope package-exactly, closing a path where the scope probe - could match a different package's draft in the caller's org and the - package-exact promote then 404'd over the caller's own publishable draft - sitting env-wide. A publish that states `?package=` no longer discovers a - package-less draft of the same `(type, name)` — retry without the query - parameter for that draft. - -## Highlights — 17.3.0 - -- **`client.projects.*` becomes `client.environments.*`, with no aliases** - (`87042b5`, ADR-0006 D2). The SDK half of one coordinated cross-repo rename: - the method namespace, the environment-scoped sub-client (`client.project(id)` - → `client.environment(id)`, `ScopedProjectClient` → `ScopedEnvironmentClient`) - and the response keys (`res.projects` → `res.environments`, `res.project` → - `res.environment`) all move together. There is deliberately no - `client.projects` getter and no `res.project ?? res.environment` hedge — - ADR-0006 D3 declined a mapping layer with reasons. The URL paths do not move; - they were already on the `environments` spelling. `os environments --format - json` payloads change with the wire. -- **Who may become a user of an environment's apps is one declaration, and its - default flips to the safe end** (`4f24e9d`). `auth.audience.posture` is - `invite_only | email_domain | open`, and an **undeclared** audience now means - `invite_only`: self-serve sign-up is refused `403 - SELF_REGISTRATION_CLOSED` unless the address holds a pending `sys_invitation` - (the first account on a fresh install is exempt). ⚠️ **A deployment that - relied on open registration changes behaviour with nothing to parse-fail on.** - The one-line fix is to declare it — `auth: { audience: { posture: 'open', - selfRegistrationPermissionSet: 'member_default' } }`. -- **A permission-store read failure fails LOUD** (`6a180e4`). `tryFind` answered - a thrown read exactly the way it answered an empty one, so an outage of the - store resolved as a well-formed context for an authenticated principal holding - no capabilities — an administrator was told they lack a capability, during an - outage of the store that holds the capability. An unreachable store now raises - `AuthzStoreUnavailableError`, carrying the existing `SERVICE_UNAVAILABLE` code - and `503`. A reachable-but-empty store, an unprovisioned `sys_*` table and a - genuine denial all keep their previous answers. -- **`driver-memory` enforces the uniqueness it always declared** (`56c093c`, - `b7f645a`). `InMemoryDriver` enforced none: a `unique: true` field and a - `unique` object-level `indexes[]` entry were declared-and-not-enforced, so a - colliding write landed and a read returned both rows. Both surfaces now refuse - with the SQL family's envelope — `UNIQUE_VIOLATION`, `409`. **Most likely to - present as "our seed data stopped loading"** on a dev or demo stack that - relied on the store accepting a duplicate; every one of those refusals is a - write the SQL family already refused. -- **`sys_record_share` is tenant-scoped, and the rows written before it need an - operator-invoked backfill** (`3f64fe6`). Every grant row on every deployment - was written with `organization_id = NULL`; the writer is repaired and - `planSysRecordShareOrganizationBackfill` / `runSysRecordShareOrganizationBackfill` - (dry run first, by default) stamp the existing rows from the record they grant - access to. -- **`POST /api/v1/automation/:name/toggle` requires `manage_metadata`** - (`266436a`). An authenticated caller without it is answered **403 - `PERMISSION_DENIED`** where it previously received `200` with the flow's - enablement changed. The execution doors (`trigger`, `resume`) and the reads are - untouched. -- **Four SDK methods stop handing you the dispatcher envelope** (`db16b94`). - `analytics.query` / `analytics.meta` / `analytics.explain` and - `automation.trigger` now resolve to the payload like every other method: - `r.data.rows` → `r.rows`. Three of the four turn every old read into a compile - error; `automation.trigger` is the exception, because `r.success` and `r.error` - compile before *and* after while their meaning moves from the envelope's flag - to the run's own. -- **Compound metadata addressing is retired** (`7986d97`). - `GET`/`PUT /api/v1/meta/:type/:section/:name` and the `.../published` sibling - answer `404 ROUTE_NOT_FOUND`; address every item through the single-segment - route with the name percent-encoded. `@objectstack/client` already sends the - new spelling, and the encoding is a no-op for every name the item-name grammar - admits. -- **The `RestServerConfig` blocks are parsed rather than ignored** (`1394768`, - `8965398`, `f60ab90`, `b3a63d3`). `config.api`, `api.projectResolution` and - `config.crud` are now parsed by name, and the ten inert keys the liveness - ledger recorded as `dead` are gone — a key that used to be accepted and - silently dropped is refused by name. -- **The largest ADR-0049 enforce-or-remove wave of the line.** Authorable - surfaces that were declared and never enforced are removed rather than - maintained: the plugin manifest's `contributes` block loses its last ten - members and `manifest:` itself goes strict (`be21955`, `bc56e18`, `dce5cd4`, - `4d0d944`), `page.components[].responsive` and the `ResponsiveConfig` - vocabulary go (`2a6122b`), the `allowRestore` / `allowPurge` object-permission - bits go (`8af88dd`), and preview mode goes with them (`0c2334f`). Because the - authorable surface has been strict since 17.0.0 (#4001), each of these is a - **parse-time refusal naming the key** rather than a silent drop. -- **Console:** four objectui pin moves — `190fbd01d061 → 9602dc820450 → - d8ec8d6d4f01 → 67dadd602a3a → 00d3f09c500c` (`df59de0`, `83be460`, `7642aac`, - `41b5a44`). - ---- - -## 17.0.0 in detail - -### Breaking changes & migration - -#### Node.js 22 is the supported floor (#3825) - -`engines.node` moves from `>=18.0.0` to `>=22.0.0` across all 50 published -manifests. Node 18 reached end-of-life on 2025-04-30 and Node 20 on 2026-04-30, -so the old range promised two runtimes nobody patches and nothing in the repo -verifies. - -```bash -nvm install 22 && nvm use 22 -``` - -**On Node 22 or newer, nothing changes** — Node 24 and 26 both satisfy the -range. npm and pnpm surface an unsatisfied `engines` as an `EBADENGINE` -warning rather than a hard failure, so an existing install will not break the -instant you upgrade; the package is simply no longer tested there. If your CI -pins Node, pin it to 22 as well. - -#### `allowExport` is opt-in — export no longer inherits read (#3544, #3710) - -| | before | after | -|---|---|---| -| `allowExport` unset | export **allowed** (inherited read) | export **denied** | -| `allowExport: false` | denied | denied (unchanged) | -| `allowExport: true` | allowed | allowed (unchanged) | - -The one-line fix — add the grant to the object entry (or the `'*'` wildcard) of -every permission set whose holders should keep exporting: - -```ts -objects: { - deal: { allowRead: true, allowExport: true }, // ← add the grant -} -``` - -- **Package-shipped sets are re-seeded on upgrade**, so `admin_full_access` and - `organization_admin` carry `allowExport: true` for you. - **Environment-authored sets are not** — edit any custom set whose users - export. `member_default` deliberately does *not* carry the grant, so ordinary - authenticated users lose export until an admin grants it. That is the point of - the flip. -- **Merge is most-permissive**, exactly like the CRUD bits: any set granting - `true` grants export; `false` and unset are the same outcome. -- **`viewAllRecords` / `modifyAllRecords` no longer imply export.** Separating - "may see all data" from "may take a bulk copy" is the segregation-of-duties - case the axis exists for. -- A set carrying `allowExport` is now **high-privilege**, so it cannot be bound - to the `everyone` / `guest` audience anchors — otherwise the opt-in was - defeatable by binding an exporting set to an anonymous anchor. - -Read/CRUD/RLS/FLS/sharing are untouched, and reports are covered by the same -axis. - -#### `enable.apiMethods` shrinks to the six primitives (#3543) - -The authorable enum is now exactly `get`, `list`, `create`, `update`, `delete`, -`bulk`. The eight legacy values are **derived** effective operations resolved by -the server's single derivation table, not things you declare. - -| FROM (legacy) | TO (primitives) | why | -|---|---|---| -| `upsert` | `create`, `update` | upsert ⊆ create ∧ update | -| `import` | `create`, `update` | import ⊆ create ∨ update | -| `export` | `list` | export ⊆ list | -| `aggregate` | `list` | aggregate ⊆ list | -| `search` | `list` | search ⊆ list ∧ `searchable` | -| `history` | `get` | history ⊆ get ∧ `trackHistory` | -| `restore` | *(delete the value)* | never derives — `enable.trash` retired | -| `purge` | *(delete the value)* | never derives — `enable.trash` retired | - -Replace each legacy value with the primitives it derives from, de-duplicate, and -if the result names all six, **delete the `apiMethods` key entirely** — that is -equivalent to default-open and it tracks future primitives. - -```bash -node scripts/codemod/apimethods-legacy-to-primitives.mjs -``` - -The codemod is a **reporter**: it scans, prints the exact replacement per site, -and flags whitelists the mapping would *widen* so every edit stays reviewable. - -**Stored metadata keeps parsing.** A stored legacy value is not a parse error — -`stripLegacyApiMethods` removes it with a FROM→TO warning. Stripping only ever -*narrows*. Watch one cliff: a whitelist of **only** legacy values (e.g. -`['upsert']`) strips to `[]`, which is **deny-all** — the object's API closes -rather than widening. - -#### An action must be declared to be invocable, and is identified by `name` (ADR-0110, #3935) - -Two halves of the `/api/v1/actions/:object/:action` contract were broken in -complementary ways, which is why neither surfaced: the route resolved the -**declaration** from the URL segment as a `name`, but dispatched the -**handler** using that same segment as a registry key. For a target-bound -action (`{ name: 'complete_task', target: 'completeTask' }`) those differ — so -the documented `curl .../todo_task/complete_task` resolved the declaration and -then 404ed, while the Console's `target`-addressed call dispatched fine and -resolved **no** declaration, silently skipping the ADR-0066 D4 capability gate -and the ADR-0104 param contract. - -Three changes land together: - -- **Identity is `name`.** The URL, MCP `run_action`, and every future surface - identify an action by its declarative `name`. `target` is a binding - expression — polymorphic per type, `${param.X}`-interpolatable, and legally - non-unique — so it never identifies anything. The server derives the handler - key from the declaration it resolved, using the rotation the MCP bridge - already used. The Console posts `name` (objectui ships in lockstep). -- **An undeclared handler is refused.** `engine.registerAction` with no - matching declaration has no `requiredPermissions` to enforce and no param - contract to check, yet executes with system privileges. It now returns 404 - naming the `defineAction` to add. Deleting a declaration used to *remove* an - action's gate while leaving it callable; removal now narrows. -- **An unreachable metadata plane refuses instead of degrading.** A loader - failure used to be indistinguishable from "no declaration", so an outage - silently ungated every action it could not see. That is now a **503** — the - same posture as the datasource entry below. - -**Migration:** boot logs list every registered-but-undeclared handler under -`[action-governance]`, alongside declared script actions bound to no handler. -Declare each one with `defineAction`, or drop the registration if nothing should -invoke it over HTTP. **There is no opt-out flag** — a switch that ran an -ungoverned handler would be the same fail-open this change closes, and a sweep -of the platform, every package and every example found zero undeclared handlers, -so it would have shipped a way to reopen the gate for a case nobody has -observed. The failure is bounded: the app still boots and every declared action -still works; only an undeclared one returns 404, and it names the `defineAction` -to add. - -Apps whose actions are all declared — anything with working Console buttons — -need no changes, other than gaining enforcement of the `requiredPermissions` -they already declared. Callers that hard-coded a `target` in an action URL -switch to the action's `name`. - -#### An action's declared params are enforced at dispatch (ADR-0104 D2, #3438) - -An action's `params[]` (`required`, `options`, `multiple`, `reference`) was a -complete contract that informed **only the client dialog**. The server passed -`reqBody.params` to the handler unvalidated on both the REST and MCP paths, so -a wrong bag could return a success envelope while the handler quietly ignored -it — the #3405 shape, one layer out. It is now checked before the handler runs; -a violation is `400 VALIDATION_FAILED` (REST) or a thrown error (MCP). - -| Violation | Example | -|---|---| -| missing `required` param | declared `p_text` absent from the bag | -| value outside `options` | `p_priority: 'NOT_AN_OPTION'` | -| `multiple` shape | a bare string where an array is declared | -| `reference` shape | a non-id where `reference: 'sys_user'` is declared | -| undeclared key | `bogus: 123` | - -```diff -- OS_ACTION_PARAMS_STRICT_ENABLED=1 # removed — enforcement is the default -+ OS_ALLOW_LAX_ACTION_PARAMS=1 # escape hatch: warn and pass, as before -``` - -**Only already-wrong calls break**, and each rejection names the offending param -and the declared list, so the fix is one edit at the call site. Actions -declaring no `params` are untouched — there is nothing to validate against — -and the dispatcher's own `recordId` / `objectName` are allowlisted, so keys -dispatch merges in are never unknown-key errors. If an integration you cannot -reach in time is affected, `OS_ALLOW_LAX_ACTION_PARAMS=1` restores the old -pass-through in one restart; the violation still logs once per action, so the -drift stays visible rather than becoming invisible again. - -The RC line carried this warn-first behind an `OS_ACTION_PARAMS_STRICT_ENABLED` -opt-in. **That variable does not exist in 17.0.** The window was closed on -purpose rather than deferred to 18.0: what a violation strands is a *caller*, -not data — no stored row becomes unwritable, the party who sees the error is -the party who can fix it, and the hatch makes it reversible. Deferring would -have charged every deployment a second upgrade ceremony to postpone a break -that costs one edited call. The value-shape half of the same ADR went the other -way for the opposite reason: it rejects on the basis of data already at rest, -so it stays gated per deployment (see *Files become platform records* below). - -#### An action param's picker target is `reference`, and only `reference` (objectui#3203) - -`ActionParam` in `@object-ui/types` no longer declares the nine resolved-side -picker keys — `referenceTo`, `displayField`, `idField`, `descriptionField`, -`titleFormat`, `lookupColumns`, `lookupFilters`, `lookupPageSize`, `dependsOn`. -Two rewrites cover everything authored against them: - -```diff -- { name: 'account_id', type: 'lookup', referenceTo: 'account' } -+ { name: 'account_id', type: 'lookup', reference: 'account' } -``` - -- **The inline picker target** — spell it `reference`, as above. It is the only - authorable way to name an inline `lookup` / `master_detail` param's target - object, and `ActionParamSchema` refuses a targetless inline picker at parse - time rather than letting it degrade into a paste-a-UUID text box (#3405). -- **The other eight** — make the param **field-backed** (`{ field: 'account_id' }`) - and the whole picker group is inherited rather than restated: `displayField`, - `descriptionField`, `lookupColumns`, `lookupFilters`, `lookupPageSize` and - `dependsOn` are `FieldSchema` keys, resolved at runtime from the referenced - field's own metadata. `idField` and `titleFormat` were never authorable on - either side — the picker resolves record identity itself, and a candidate's - label comes from the referenced object's `nameField` (ADR-0079). - -**This removes a compile-time illusion, not a capability.** None of the nine was -ever storable: `ActionParamSchema` is `.strict()`, its authorable key list -carries `reference` and not `referenceTo`, and its alias table names -`referenceto → reference` by hand (`packages/spec/src/ui/action.zod.ts`) — so an -authored `referenceTo` has always been a hard parse rejection on the server. -Only `tsc` waved it through, against objectui's public type, which moved the -failure from the authoring keystroke out to publish time. `ActionParam` is now -derived from the spec schema (`Omit, 'type'>`), -so the authoring type and the parser can no longer disagree about a spelling, -and `resolveActionParams()` names any resolved-only key it still meets in a -dev-mode warning carrying the prescription above — which covers the params -authored in plain JS or JSON, where `tsc` never looks. - -#### A flow run with no trigger user may not touch data (#3760) - -An effective `runAs: 'user'` run that resolved **no trigger user** used to -execute its data nodes *unscoped* — it presented no principal, the data security -middleware skips when there is no principal, and the run then read and wrote -every row. `runAs: 'user'` is an access-**narrowing** declaration; failing to -resolve it must never resolve to a grant. It now refuses with -`UnscopedRunDataAccessError`. - -This was never really a schedules problem. `isSystem` does not suppress trigger -dispatch — only `skipTriggers` does — so every plugin/service system write, the -approvals status mirror, and a `runAs: 'system'` flow's own data node dispatched -record-change flows with `userId: undefined`. Unprivileged input reached that -path routinely. - -**Migration:** a flow that reacts to system writes and must act beyond one -user's grants declares `runAs: 'system'`, making the elevation explicit and -audit-attributable. Otherwise ensure the trigger supplies a user. Flows that -touch no data are unaffected, the engine warns at run *setup* before any node -executes, and the originating write still succeeds because the trigger already -swallows flow errors. - -#### The last three deprecated authorable aliases are removed (#3855) - -| Removed | Use instead | Value shape | -|---|---|---| -| `action.execute` | `action.target` | unchanged | -| `field.conditionalRequired` | `field.requiredWhen` | unchanged | -| `agent.knowledge.topics` | ~~`agent.knowledge.sources`~~ → the whole `knowledge` block was later removed (#3896 close-out — see the dead-cluster table) | — | - -All three are pure key renames — each alias was already lowered into its -canonical key at parse time, so what shrinks is the authorable surface, not the -semantics. - -```bash -os migrate meta --from -``` - -The renames are registered as protocol-17 chain steps, so one pass applies all -three plus every earlier step you skipped. Manual alternative: rename the key. - -```diff -- actions: [{ name: 'convert', type: 'script', execute: 'convertHandler' }] -+ actions: [{ name: 'convert', type: 'script', target: 'convertHandler' }] - -- fields: { due_date: { type: 'date', conditionalRequired: 'record.stage == "closed"' } } -+ fields: { due_date: { type: 'date', requiredWhen: 'record.stage == "closed"' } } - -- knowledge: { topics: ['faq', 'policies'], indexes: ['docs'] } -+ (delete the block — `agent.knowledge` was removed outright in the #3896 -+ close-out: declaring sources never scoped retrieval) -``` - -Each removed key is **tombstoned** as `never` rather than simply deleted, so -writing it is a `tsc` error at the authoring site *and* a parse error carrying -the rename — none of these schemas is `.strict()`, so a plain deletion would -have made Zod silently strip the key and the setting would have quietly stopped -taking effect. - -`lintDeprecatedAliases` and its rule-id exports are removed with them: that pass -existed to warn when an author declared both an alias and its canonical key, -which the parse now rejects outright. Delete the import; there is no replacement -because the condition can no longer occur. - -#### Seven flow-node config key aliases graduate into the conversion layer (#3796) - -| Node type(s) | Deprecated | Use instead | -|---|---|---| -| `get_record` / `create_record` / `update_record` / `delete_record` | `config.object` | `config.objectName` | -| `notify` | `config.to` / `config.subject` / `config.body` / `config.url` | `config.recipients` / `config.title` / `config.message` / `config.actionUrl` | -| `script` | `config.functionName` / `config.input` | `config.function` / `config.inputs` | - -All are pure key renames with unchanged values. Unlike the three tombstoned -aliases above, these **cannot** be rejected in the schema — -`FlowNodeSchema.config` is an unconstrained record — so they keep a load-path -acceptance window instead: a stored flow authored with an alias keeps loading -through protocol 17, rewritten to the canonical key at load (including -`AutomationEngine.registerFlow` rehydration) with a `ConversionNotice`; the -window retires in 18. The executors read canonical keys only, and the -`readAliasedConfig` executor shim (which covered `object` → `objectName` and -warned at run time) is deleted — the conversion layer is now the single seam -that declares, converts, and retires flow-config aliases. - -`os migrate meta --from ` rewrites all seven in your -source; or rename the keys by hand. `actionUrl` is the deliberate canonical of -its pair: the downstream chain already uses that name -(`sys_notification.action_url`, the channel contract, the REST notification -model), while `url` platform-wide means an HTTP endpoint to call (`http` node, -webhooks). The singular `input` on `map` / `subflow` / `connector_action` is -those nodes' own canonical key and is untouched. - -#### Authorable schemas reject unknown keys (#4001) - -Zod's default is `.strip`: a key a schema does not declare is silently -discarded and the instance keeps parsing. On an authorable surface that is the -worst failure mode — the author (human or AI) gets a success envelope and -ships metadata that quietly ignores their config; that is exactly how a -correctly intended action-param `reference: 'sys_user'` degraded into a -paste-a-UUID text box (#3405). 17.0 extends #3746's strictness from that one -schema to the two highest-risk authorable surfaces, per the triage in -`docs/audits/2026-07-unknown-key-strictness-ledger.md`: - -- **Permission sets** — `PermissionSetSchema`, `ObjectPermissionSchema`, - `FieldPermissionSchema`, `AdminScopeSchema`. A silently dropped key on the - capability container meant the author believed a grant or restriction was in - place that the runtime never saw. The response-side - `EffectiveObjectPermissionSchema` stays wire-tolerant. -- **Flows** — `FlowSchema`, `FlowNodeSchema`, `FlowEdgeSchema`, - `FlowVariableSchema`. A node's `config` stays an **open** record: it is - per-node-type, owned by the executor's `configSchema` and the conversion - layer (see the alias table above). -- **RLS policies** — `RowLevelSecurityPolicySchema`. A silently dropped key - here meant a row-level restriction the author wrote was never compiled into - the filter (the pre-ADR-0090 D3 vocabulary → `positions`, - `withCheck` → `check`, `condition`/`filter`/`where` → `using`). The runtime - evaluation shapes stay tolerant. -- **Sharing rules** — the rule, its criteria extension, and the `sharedWith` - recipient (`criteria` → `condition`, `access` → `accessLevel`, - `recipient` → `sharedWith`; `ownedBy` carries the removed owner-type-rule - prescription). -- **Positions** — `PositionSchema`, including the guidance that - `permissionSets`/`users` are runtime bindings and `parent` has no meaning on - a deliberately flat position (ADR-0090 D3). Position also gains the - `protection` block and ADR-0010 runtime envelope every sibling registered - type already declared. -- **The app shell** — `AppSchema`, its branding / area / context-selector / - contribution blocks, and the whole navigation tree. The nav-item union is - now DISCRIMINATED on `type`, so one unknown key yields one precise issue - against the branch you wrote (at an exact path through nested `children`) - rather than a nine-branch `invalid_union` wall. Per-target payloads - (`params` on page / component / action items) stay open. The gate's first - catch here was in the platform's own Account app: three navigation groups - declared `defaultOpen` — never a schema key — and so shipped collapsed - while their author believed they opened by default (`expanded` is the key). -- **Approval nodes** — all four authoring schemas (node config, approver, - escalation, decision-output). Process-era keys carry the ADR-0019 re-home - map (`steps` → successive approval nodes, `entryCriteria` → the entering - edge's condition, `onApprove`/`onReject` → the `approve`/`reject` - out-edges, `rejectionBehavior` → a declared back-edge). The published JSON - schema carries `additionalProperties: false` into the Studio form and - `registerFlow()` config validation, so a mis-keyed approval `config` is - rejected at registration too. -- **Hooks** — `HookSchema`, its `retryPolicy`, and both hook-body branches - (`expression` and `js`). A body was the worst place to lose a key quietly: a - misspelt `capabilities` stripped to the empty default and the sandbox then - threw at *invocation* time, on whichever code path first touched `ctx.api` — - far from the typo. A misspelt `timeoutMs`/`memoryMb` silently downgraded the - body to the enclosing hook's looser limits. Two near-miss spellings now carry - aliases because they are genuinely easy to cross: hook-level `timeout` vs - body-level `timeoutMs`, and hook `retryPolicy.backoffMs` vs datasource - `retryPolicy.baseDelayMs`. `HookContextSchema` — the runtime shape the engine - hands your handler — stays tolerant and always will. -- **Datasources** — `DatasourceSchema` with its `pool` / `healthCheck` / `ssl` / - `retryPolicy` blocks, the ADR-0015 `external` federation settings and their - `validation` policy, `DatasourceCapabilities`, and `DriverDefinitionSchema`. - `config` stays an **open** record: its shape is per-driver. What it no longer - is, is unvalidated — #4410 wired the per-driver schemas - (`PostgresConfigSchema` and siblings) into `DatasourceSchema`'s refinement, so - a misspelling one level *down* is now rejected with the canonical key named. - An earlier version of this note said the driver's own `configSchema` did that, - which was wrong for two releases: the field existed, nothing read it. That - openness is why the - top level had to close — a connection key written one level too high (`host` - next to `driver` instead of inside `config`) was stripped, and the datasource - then connected on driver defaults rather than failing. Those keys now - prescribe the move into `config`; a top-level `password` is instead pointed at - `external.credentialsRef`, because relocating an inlined secret is not the fix. - A dropped key in `capabilities` was quieter still — though not for the reason - this note used to give. It claimed an unregistered capability "reads as - `false`, so the engine stopped pushing that work down to the driver and - recomputed it in memory", which was never true: the #4487 liveness audit - found the whole `capabilities` block has no reader at all. The engine gates - pushdown on the runtime driver's own `supports.*` object, a different - mechanism with a non-overlapping vocabulary. Every key in the block is `dead` - in `liveness/datasource.json`, and `capabilities.readOnly` is the one to know - about: it reads as a safety switch and gates nothing — `external.allowWrites: - false` is the enforced write gate. - -One clarification, since these flips are easy to over-read: making a schema -strict does **not** change its published JSON Schema. `build-schemas.ts` -converts with `io: 'output'`, and in output mode zod emits -`additionalProperties: false` for a `.strip()` object too — the post-parse shape -genuinely has no extra keys. So the JSON Schema was already advertising -`additionalProperties: false` while the zod parse quietly accepted and discarded -unknown keys. These flips align the parse with the contract that was already -published; they do not widen it. - -Every rejection is written to be self-fixing: it names the offending key and, -where recognisable, the canonical spelling (`steps` → `nodes`, edge -`from`/`to` → `source`/`target`, `read` → `allowRead`, `tabs` → -`tabPermissions`), a wrong-layer pointer (a flow-level `objectName` belongs on -the start node's `config`; `apiOperations` is response-side only), or a -retired-key tombstone (`contextVariables` → ADR-0105 D11's `rlsMembership` -resolvers; `isProfile` → `isDefault`, ADR-0090 D2). - -Migration is by construction behavior-preserving: any key now rejected was -previously stripped, so it never had a runtime effect — remove it or rename it -to the canonical key the error suggests; nothing about a working app changes. - -The gate paid for itself immediately by exposing keys the platform *writes* -but the spec could not express, so `PermissionSetSchema` gains three: the -**`description`** shown in Setup (persisted to -`sys_permission_set.description`), the author-facing **`protection`** block, -and the ADR-0010 **runtime protection envelope** (`_lock`, `_packageId`, -`_provenance`, …) that every sibling metadata type already carried — without -it, a package-owned permission set could not round-trip through an -environment overlay. - -#### `agent.tools[]` is removed — capability comes from skills (ADR-0109, #3820) - -ADR-0064's invariant is "an agent's tool set is the union of its -surface-compatible skills' tools; nothing falls through to the global registry". -The inline `tools` slot was the one seam that broke it — the runtime resolved -`agent.tools[].name` against the **full** registry with no surface check, so an -`ask`-surface agent could name an authoring tool and get it. `AIToolSchema` and -the `AITool` type go with it. - -**Migration:** attach capability through `skills`. An agent authoring `tools` is -not a parse error — the key is tombstoned and stripped — so existing stacks keep -parsing, but the slot no longer does anything. - -Two lint changes ride along: `validate-ai-tool-references` now models AI -exposure the way the runtime does (a tool materialises only when the action -declares `ai.exposed: true` + `ai.description` **and** has a headless path — -`url`/`modal`/`form` are UI-only), and a new `validate-ai-agent-authoring` rule -warns on a stack declaring `stack.agents`, which the runtime has filtered from -the catalog since ADR-0063. - -#### Error codes are one SCREAMING_SNAKE vocabulary, and `error.code` is a closed set (ADR-0112, #3841) - -The platform shipped two live error-code dialects: `StandardErrorCode` declared -lowercase `snake_case` members while 139 codes on the wire were SCREAMING_SNAKE, -and `ApiErrorSchema.code` was a bare `z.string()`, so nothing validated either. -Now there is one vocabulary — the standard catalog plus `ERROR_CODE_LEDGER` for -service-specific codes — and `error.code` validates against their union, so an -unregistered code fails parse instead of quietly becoming a new dialect. - -**Wire-visible.** Codes change spelling on every surface that spoke lowercase. -Generic conditions collapse onto the standard catalog rather than keeping a -synonym: `unauthorized`/`unauthenticated` → `UNAUTHENTICATED`, `forbidden` → -`PERMISSION_DENIED`, `not_found` → `RESOURCE_NOT_FOUND`, `internal` → -`INTERNAL_ERROR`, `unavailable` → `SERVICE_UNAVAILABLE`, `not_supported` → -`NOT_IMPLEMENTED`, `bad_request` → `INVALID_REQUEST` (a status name is not a -semantic code). Domain conditions get a registered SCREAMING code. - -**Migration:** branch on `error.code` **values**, and do not pattern-match their -case. A code branch that misses fails *silently* — nothing throws, the affordance -it guards just disappears — which is how eleven console branches broke without a -single test failing (objectui#2977). If you support servers on both sides of the -upgrade, compare case-insensitively; that is what the console does. - -Four routes also stop putting a code in the message slot: the webhook redeliver -route, the API-trigger webhook and two `rest` routes answered -`{ success: false, error: '', message }`. They now emit -`error: { code, message }`, so a client reading `body.error` as a string on those -routes must read `body.error.code`. - -Not swept, deliberately: `sys_metadata_audit.code` (persisted audit history, and -the column also holds non-errors like `ok`), diagnostics records that ship inside -a 200, field-level codes (see below), and the CLI's `--json` output contract. - -#### Field-level error codes are their own closed catalog, and `fieldErrors` becomes `fields` (ADR-0114, #3977) - -The per-field `code` inside `fields[]` is a **different** vocabulary from -`error.code`, and it stays lowercase on purpose: a top-level code names the -condition the *request* hit, while a field-level code names the *constraint* the -value violated — and constraints are declared in the metadata's own snake_case, -so `max_length` the code and `max_length: 50` the property are the same word. -`FieldErrorCode` closes that set (27 members) and `FieldErrorSchema.code` -validates against it, where it used to be `z.string()`. - -**`EnhancedApiError.fieldErrors` is renamed to `fields`** — the name every -producer already emitted. The old key was declared and emitted by nobody, so a -reader keying on it was reading a field no server sent. It is tombstoned rather -than deleted: writing it now fails with the rename instead of parsing clean and -losing the array. - -**Wire-visible.** Routes that validate with Zod stopped leaking Zod's issue codes: -`too_small` now becomes `min_length` / `min_value` / `min_items` depending on what -was too small, `unrecognized_keys` becomes `unknown_field`, and — the one that was -a real bug — a **missing** required property now reports `required` instead of the -`invalid_type` Zod uses for it, so a form marks it as missing rather than -wrong-typed. - -**Migration:** read `error.fields`; branch on the catalog's lowercase codes for -per-field handling, and show `message` to the user rather than the code. - -#### Approval requests are visible to participants, not the whole tenant (#3590) - -`getRequest` / `listRequests` / `countRequests` query with `SYSTEM_CTX` to -bypass RLS because approver visibility spans identity forms RLS cannot model — -but only the **tenant** half of that rule was ever written. Any authenticated -user could read any approval request in their tenant, including its payload -snapshot, its full decision history and its attachments. `approverId` on -`listRequests` is a *filter*, not authorization: omitting it returned the whole -tenant. Callers that relied on the wide read must now be participants (or hold -the admin override). - -#### Sharing: the `full` access level is gone, and the recipient enum matches the runtime (#3865, #1878) - -- **`accessLevel: 'full'` is removed.** It advertised delete/transfer/share and - granted plain `edit`. Stored rules convert to `'edit'` — the access they - actually had — through a protocol-17 conversion, so nothing silently gains or - loses reach. -- **`sharedWith.type: 'group'` → `'team'`** (wire rename), matching the ADR-0090 - D3 vocabulary the runtime already expands via `sys_team` / `sys_team_member`. - The old spelling was silently skipped at seed time. -- **`business_unit` is added** to the authoring enum — exactly one business - unit's members, no subtree (use `unit_and_subordinates` for the subtree). The - runtime already enforced it; only the enum omitted it. -- `guest` and the owner-type rules are pruned. Every authorable recipient and - rule type is now enforced. - -#### Sharing rules: an empty criteria shares nothing (#3896) - -A rule stored without criteria — missing, `null`, `{}`, unparsable, or a -misspelled key (`criterias`) — used to evaluate as `find(object, { filter: {} })` -under the system context: **every record of the object, granted to the -recipient**, up to the evaluator's 5000-record window. Three write paths -accepted that shape without validation: `POST /api/v1/sharing/rules`, a direct -`sys_sharing_rule` insert (what authoring in Setup issues), and the seed -bootstrap's own match-all branch. The field description even advertised it — -*"leave empty to share every record"* — which was never a feature, only a name -for the bug. - -Now, in three layers: - -- **`defineRule` rejects** a match-all criteria with `VALIDATION_FAILED`. -- **The evaluator matches nothing** for such a rule and logs why. This is the - layer that matters for already-deployed tenants: a row stored before this - gate **under-shares instead of over-sharing, and the next reconcile revokes - the grants it had materialised**. No data migration is needed. -- **A `sys_sharing_rule` insert fails field-level** (`fields[].field = - 'criteria_json'`), so Setup marks the Criteria input instead of saving a - rule that silently does nothing. The Console builder says so before you - save (objectui#2962) and renders the server's reason on the field - (objectui#2966). - -There is no "share every record" sharing rule: object-wide read is the -object's organization-wide default (`sharingModel`). A rule that relied on the -empty shape must state its predicate, or the object should use `sharingModel`. - -#### RLS: `enabled` is enforced, `priority` is removed (#3980, #3990) - -- **`enabled: false` now actually disables a policy.** The schema always said - *"Disabled policies are not evaluated"*, but nothing read the property — and - because applicable policies OR-combine (any match allows access), a policy an - admin switched off **kept granting row access**. The gate is at the single - choke point both the find and analytics paths flow through; `enabled` absent - stays active, so no stored policy changes behaviour. Access-narrowing only — - but re-check any policy you set `enabled: false` on expecting no effect, - because until now it had none. -- **`priority` is removed.** It promised "conflict resolution" that cannot - exist: with OR-combination there is never a conflict to order, and evaluation - order cannot change an outcome. Nothing ever read it. Authored values are - tombstoned with a fix-it prescription; `os migrate meta` deletes the key - mechanically, and policy outcomes are identical with or without it. - -#### `required` is a write contract; the column constraint is `storage.notNull` (ADR-0113) - -`field.required` used to mean three things through one knob: the write check, -the physical `NOT NULL`, and the drift expectation. Bound together, tightening -any invariant on a deployed object was a destructive migration blocked by the -very legacy nulls that motivated it — so real invariants ended up as imperative -guards (three of them for `sys_sharing_rule.criteria_json`) or didn't happen. -Split in v17: - -- **`required: true`** is the write-time contract, uniformly: an insert must - provide a non-null value, an update may not null it out — a PATCH clearing a - required field is now rejected (it silently passed before) — and legacy null - rows rest: a write that doesn't touch the field never blocks. The column - stays nullable. -- **`storage: { notNull: true }`** is the explicit physical constraint. It owns - the `NOT NULL` DDL and the destructive-migration ceremony (backfill first). - Declaring it at field creation is free; declaring it over existing nulls is - loud. -- **`requiredWhen` inherits the same non-regression rule**: a write that flips - the condition true without providing the field is rejected (it *creates* the - violation), while a row violating since before the rule tightened no longer - locks out unrelated edits. `storage.notNull` × `requiredWhen` is rejected at - parse — a conditional contract cannot be an unconditional constraint. -- **Pre-17 sources keep their exact meaning**: `os migrate meta` stamps - `storage: { notNull: true }` onto every previously-required field - (`field-required-notnull-explicit`) — under the old semantics that column - *was* created `NOT NULL`, so the rewrite writes down what the text already - meant. Migration-chain-only: the loader never guesses. -- **Drift**: nullability now compares against `storage.notNull`. A column - stricter than its declaration is `needs_confirm` (ratify or relax — dev - auto-reconcile no longer silently strips a stray `NOT NULL`), and silent when - the field is `required` (the write gate makes the constraint unreachable). - -#### Membership grade is not a capability channel (ADR-0108, #3723) - -`sys_member.role` answers "what is your standing in this organization", not -"what may you do" — but `resolve-authz-context` projected **every** value stored -there into `current_user.positions`, so business capability handed out through -the membership grade *was* capability — granted with none of the position -system's controls (no `granted_by`, no effective dating, no ADR-0091 checks). -The vocabulary is now closed. Move business capability to positions -(`sys_user_position`). - -#### better-auth 1.7.0-rc.2 — account identity restructuring - -better-auth renamed `account.accountId` to `account.providerAccountId` and added -a **required** `account.issuer`; sign-in now resolves accounts by -`(issuer, providerAccountId)`. - -- FROM `fields: { accountId: 'account_id' }` → TO - `fields: { issuer: 'issuer', providerAccountId: 'account_id' }`. The provider - account id keeps its `account_id` column; `sys_account` gains an `issuer` - column. -- FROM `internalAdapter.createAccount({ providerId, accountId, … })` → TO - `createAccount({ providerId, issuer, providerAccountId, … })`. A local - password account carries better-auth's own `local:credential` issuer. -- FROM `client.auth.accounts.unlink({ providerId, accountId })` → TO - `unlink({ accountId })`, where `accountId` is the account **row** id from - `accounts.list()`. That listing now returns `issuer` + `providerAccountId` in - place of `accountId`. - -**Existing deployments:** rows written before 1.7 have no issuer and are -invisible to sign-in until stamped. The auth plugin runs an idempotent -boot-time backfill that derives what it can — `local:credential` for password -accounts, `local:oauth:` for configured social providers, and the -registered IdP's real `iss` from `sys_sso_provider` for federated ones. Accounts -from a federated IdP that is **no longer registered** cannot be derived; they -are logged with their provider id and row count rather than guessed, and those -users cannot sign in through that provider until the row is stamped or removed -so a fresh login re-links it. - -`@better-auth/scim` deliberately stays on 1.7.0-rc.1 — rc.2 replaces its whole -model, which is a feature migration rather than a version bump. - -#### Dead SDK surface deleted (#3612, #3702, #3718, #3731) - -Five client families built URLs that exist on **no** server surface, so every -call was a guaranteed 404. They are removed, along with the four unconsumed -`DEFAULT_*_ROUTES` tables that underwrote them: - -- `client.permissions.*` (check, getObjectPermissions, getEffectivePermissions) -- `client.realtime.*` — `service-realtime` registers zero HTTP routes -- `client.workflow.*` (getConfig, getState, transition) -- `client.views.*` CRUD — there is no `/ui/views` route anywhere -- `client.notifications` device/preference helpers — the ADR-0012 server side - was never built -- `client.ai.{nlq,suggest,insights}` — the whole namespace, rebuilt below -- `client.projects.listTemplates()` — targeted `GET /api/v1/cloud/templates`, - mounted by nothing -- `os environments create --template` and its `template_id` body field — the - flag was accepted, transmitted and dropped with no seeding, no error and no - stored trace - -**Console consumers:** the companion objectui change trims the matching dead -delegates from `useClientNotifications` (`@object-ui/react`), so a host calling -`registerDevice` / `unregisterDevice` / `getPreferences` / `updatePreferences` -through that hook loses them at the same time (objectui#2862). - -Kept: `client.events` (explicitly a local in-memory buffer), the -dispatcher-served `notifications.list/markRead/markAllRead`, `approvals.*`, and -`meta.getLegalNextStates`. Re-adding any removed surface now requires the server -route to exist *and* a route-ledger row proving it. - -Two REST↔client mismatches are reconciled at the same time: marketplace publish -moves from `POST /api/v1/packages` to **`POST /api/v1/packages/publish`** (the -bare path collided with install semantics and REST wins first-match, so every -`packages.install` call 400'd), and `meta.getView` stops speaking a `?type=` -query dialect only the dispatcher understood. - -#### The `ai` namespace now expresses the AI surface that exists (#3718) - -| SDK | Route | -|---|---| -| `ai.chat(request)` | `POST /api/v1/ai/chat` — forces `stream: false` | -| `ai.chatStream(request)` | `POST /api/v1/ai/chat` — `AsyncIterable` of UI Message Stream frames | -| `ai.complete(request)` | `POST /api/v1/ai/complete` | -| `ai.models()` | `GET /api/v1/ai/models` — the plan-filtered picker list | -| `ai.conversations.*` | the six `/api/v1/ai/conversations` routes | -| `ai.agents.*`, `ai.pendingActions.*` | the agent + pending-action routes | - -`ai.chatStream` returns a *promise for* an async iterable rather than being an -async generator, so the request is issued — and an HTTP error thrown — when you -call it, not when you first iterate. - -`service-ai` is a Cloud/EE package: this repo proxies `/api/v1/ai/**` and, without -it, answers 501 — the route is mounted, the implementation is not — so check -`discovery.services` before calling. For a React chat UI, `useChat()` (`@ai-sdk/react`) remains the better -client — these methods are for callers that are not components. - -The spec's dead AI declarations retire with the namespace: -`Ai{Nlq,Suggest,Insights}{Request,Response}[Schema]`, `DEFAULT_AI_ROUTES` -(`getDefaultRouteRegistrations()` returns 8 groups), and the `AiProtocol` -interface. The real server contract is `IAIService` + `IAIConversationService` -in `@objectstack/spec/contracts`. - -#### The GraphQL surface is removed (#2462) - -GraphQL was schema-only from day one: 20+ config schemas, a `handleGraphQL` that -answered 501 unconditionally because `kernel.graphql` was never assigned, and -three separate mounts advertising the dead endpoint. `api/graphql.zod.ts` and -`contracts/graphql-service.ts` are deleted; `graphql` is removed from -`CoreServiceName`, `ApiProtocolType`, the query-adapter dialects and the -discovery/router route fields. **`/graphql` now 404s** (it used to 501). - -Not removed: the `'graphql'` protocol option on **external datasource** lookups — -third-party systems may speak GraphQL, and that is not our API surface. - -#### The `ObjectStackProtocol` composition alias is dissolved (ADR-0076 D9, #3606) - -The transitional union of the twelve per-domain contracts — plus its parallel -`ObjectStackProtocolSchema` Zod object and `ObjectStackProtocolZod` inferred type, -171 schema lines — is removed. Capability availability comes from the runtime -discovery `services` registry; a static union was its degraded snapshot. - -**Migration:** depend on the narrowest per-domain slice you actually use -(`DataProtocol`, `MetadataProtocol`, …), composing them the way REST does -(`DataProtocol & MetadataProtocol`). `ObjectStackProtocolImplementation` now -declares exactly the four domains it provides — Data, Metadata, Analytics, -Package — which the type system enforces. Breaking for importers of the alias or -the Zod schema; **no runtime behaviour change**. - -`objectql`'s protocol re-exports are dropped in the same step: the protocol -assembly is single-sourced through `metadata-protocol`. - -#### A datasource that cannot connect fails the boot (#3741, #3758, #3826) - -- **A declared datasource that objects bind to must connect.** Previously only - an `external` datasource with `validation.onMismatch: 'fail'` fail-fasted; - everything else degraded to one `warn` line. An app declaring - `datasource: 'analytics'` with 20 objects bound to it, booted against a wrong - URL, started clean, exited zero, and then failed every read and write of those - 20 objects with `Datasource 'x' is not registered`. -- **`objectql.init()` refuses to boot** when a data driver fails to connect. -- **The standalone `default` datasource is now a declaration**, connected through - the one `DatasourceConnectionService` path instead of being pre-built and - smuggled in as a `driver.*` kernel service with its own copy of the - failure policy. -- **`/ready` reports 503** when a data driver stops answering, and a - down datasource is visible in Setup with the operator-facing reason. -- Connection attempts are bounded at 10s with an accurate error message. - -If you relied on a boot that survived an unreachable non-default datasource, -that boot was already broken — it just failed later and with a worse message. - -#### `unique` materializes per tenant (#3696) - -`unique: true` became a single-column **global** index that ignored `tenancy` -entirely, while the autonumber sequence table is keyed by -`(object, tenant_id, field, scope)` and hands every tenant its own counter -starting at 1. Tenant B's `PROD-00001` was rejected by an index it could not -see — and the rejection doubled as a cross-tenant existence oracle. The index is -now tenant-scoped, matching the sequence. - -#### Aggregation result shapes (#3839, #3849) - -- **One key for the empty group bucket.** Both aggregation paths now emit a real - `null` for the empty bucket instead of two different placeholder spellings. -- **A group key is the column's value, in the shape `find()` presents it.** - Grouping and reading no longer disagree about the representation of the same - column — including SQLite `Field.datetime`, which used to collapse every row - into one `(null)` bucket, and raw epoch storage that `aggregate()` / - `distinct()` leaked. - -#### i18n routes answer in the shapes they declare (#3676, #3778, #3847) - -- **`/i18n/labels/:object/:locale`** emits the declared entry object - (`{ label, help?, options? }`) instead of a bare `Record`. - A client typed against `GetFieldLabelsResponse` read `labels[field].label` and - got `undefined`; the SDK's type was right and the servers were wrong. - `help` and `options` stop being discarded. -- **`/i18n/locales`** answers in one shape, found by a new success-envelope - conformance suite that parses every `/i18n` success body against the schema - the route declares — rather than against a hand-written literal, which is what - let three of these ship green. -- **The `translation` metadata type speaks `objects.`**, the shape every - resolver, `os i18n extract`, the Console hooks and all nine shipped bundles - already used. It had been registered against an object-first `o.` - schema, so a translation authored in the product saved successfully and then - rendered nothing. Real-world footprint of the retired shape was zero — all - three `*.translation.ts` files in the tree were already `objects.`-shaped — - so this is a registration fix, not a migration. -- **`GetTranslationsRequest` is locale-only.** The `namespace` / `keys` filters - were declared, put on the query string by the SDK, and read by neither serving - surface; passing `keys` shrank nothing and reported nothing. - -#### The dispatcher's `error.code` is the semantic code, not the HTTP status (#3842) - -Everything the runtime dispatcher serves — `/meta/*`, `/actions/*`, -`/packages/*`, `/automation/*`, `/analytics/*`, `/ready`, the route-not-found -404 — used to answer with the HTTP status in `error.code`, the field -`ApiErrorSchema` declares as a semantic string. The real code had to live -somewhere else and did, in three different somewhere-elses: `error.details.code`, -`error.details.type`, and a sibling `error.type`. - -```jsonc -// before // after -{ "error": { { "error": { - "message": "…", "code": "PERMISSION_DENIED", - "code": 403, "message": "…", - "details": { "code": "PERMISSION_DENIED" } "httpStatus": 403 -} } } } -``` - -- **`error.code`** is the semantic string. **`error.httpStatus`** is the number. - **`error.details`** is context only. Replace a - `body.error.details?.code ?? body.error.type` read with `body.error.code`, and - a `body.error.code` read (for the status) with `body.error.httpStatus`. -- **SDK callers need no change.** `ObjectStackClient` already normalised this — - `err.code` semantic, `err.httpStatus` numeric. The old-shape fallback read was - retired later in this same window (#4007): SDK and server ship on one release - train, and the ADR-0112 rename changed the code values a dug-out code would - need to match. -- **No code was renamed.** `PERMISSION_DENIED`, `ROUTE_NOT_FOUND`, - `PASSWORD_EXPIRED`, `PROJECT_MEMBERSHIP_REQUIRED`, `VALIDATION_FAILED` and - `unauthenticated` all reach the wire spelled exactly as before; only their - field changed. Reconciling the platform's two code vocabularies is #3841. - A branch with no code of its own now derives a `StandardErrorCode` from the - status (`403` → `PERMISSION_DENIED`, post-rename spelling), spelled in one map in the spec. -- **Spec:** `ApiErrorSchema` gains optional `httpStatus`; `StandardErrorCode` - gains `method_not_allowed` and `precondition_required` (both additive). - `DispatcherErrorCode` changes members from `'404' | '405' | '501' | '503'` to - the four semantic spellings the removed `error.type` declared, and - `DispatcherErrorResponseSchema.error.code` becomes a string — it had declared - the opposite of `ApiErrorSchema` for the same field, which is what let the - deviation stand. - -#### Anonymous access is denied unconditionally — `api.requireAuth` is gone (#3963) - -`api.requireAuth` was a deployment-wide opt-out: one boolean that let a stack -serve its **entire** data plane to unauthenticated callers. Auth is a kernel -concern, not a deployment posture, so the key is retired and anonymous access to -object data is now denied on every HTTP surface. A stack that mounts no auth at -all fails at boot when it would serve a data API, instead of receiving an -implicit fail-open. - -Publish public surfaces by **declaration** instead — each derives its own narrow -authorization rather than opening the whole data plane: a public form view -(`sharing.allowAnonymous`), a share-link token (read as SYSTEM), or -`book.audience: 'public'` (ADR-0046 §6.7). `os migrate meta` drops the key and -emits a notice telling you where public access has to be re-declared. - -#### `wait` never had a timeout — `timeoutMs` / `onTimeout` are retired (#4158) - -Both keys described a timeout and neither delivered one. -`waitEventConfig.onTimeout` had **zero** readers — no path ever inspected it, so -neither `fail` nor `continue` ever happened, while its `.default('fail')` -stamped a decision nothing made onto every wait node. -`waitEventConfig.timeoutMs` said "maximum wait time before timeout", but its -only reader used it as the timer *duration* when `timerDuration` was absent: it -did something, just not what it claimed. A wait resumes when its timer elapses -or its signal arrives — never on a deadline. - -`os migrate meta` converts `timeoutMs` → `timerDuration` (stringified — a bare -numeric string reads as milliseconds, so the wait is unchanged) and drops it -outright when `timerDuration` was already set. `onTimeout` is deleted; there is -no replacement. Real timeout semantics are left to be built to a requirement -rather than retrofitted onto two keys that happened to be declared. - -#### The query request surface sheds five inert keys (#4196, #4286) - -`QueryAST` is a **request** shape — never stored in stack metadata — so these -are caller-side edits, not a source rewrite: `os migrate meta` has nothing to -convert. Each key now fails to parse with its prescription, and authoring one is -a `tsc` error at the call site. - -| Removed | Why | Use instead | -|---|---|---| -| `fields[]` object form `{ field, fields, alias }` | inert end to end — every reader treats the list as `string[]`, so it was dropped by the SQL and memory drivers, projected as a column named `"[object Object]"` by MongoDB, and refused by the REST ingress | `expand`, keeping the reference column in the projection (`fields: ['title', 'owner']` plus `expand`) | -| `query.joins` | no engine or driver ever read it; the name squatted on the reserved REST parameter set (the `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with it) | `expand` — the engine resolves it via batch `$in` queries | -| `query.windowFunctions` | `find()` never applied one, so every OVER clause was silently dropped; `WindowFunctionNode` declared `field`/`over`/`frame` members the live door never read | `aggregations` + `groupBy`; embedders on a SQL datasource call `SqlDriver.findWithWindowFunctions()` | -| `query.cursor` | no driver implemented keyset pagination — the cursor was accepted and ignored, so every page came back identical and a caller looping "until `hasMore` is false" never terminates | a `where` predicate on your sort key (`{ created_at: { $gt: last.created_at } }`) with the matching `orderBy` | -| `query.distinct` | **mis-wired**, not merely dead: no driver rendered SELECT DISTINCT, and its only observable effect was suppressing the REST list count — callers got duplicate rows *and* a degraded `total`/`hasMore` | `groupBy`, the `count_distinct` aggregation, or the drivers' `distinct(object, field)` door | - -`QueryBuilder.cursor()` and `QueryBuilder.distinct()` are deleted with their -keys. The count suppression goes too, so `total` is truthful again for queries -that used to send `distinct`. - -`BatchOptions.validateOnly` (#4052) is retired on the same grounds: it promised -a dry-run ("validate records without persisting") that no batch surface ever -honoured — `updateManyData`, `deleteManyData` and `batchData` persisted -regardless, so a caller sending it to *preview* a mutation got it executed. It -is HTTP-only; stop sending it. - -#### `findOne` must say which record it wants (#4419) - -`findOne` reads a single row. That makes its predicate the only thing standing -between the caller and *an arbitrary record* — and when the predicate is missing -the result is not `null`, it is the object's **first row**: a real, -plausible-looking record with nothing to do with the request, which the -`if (!row)` check every call site already has cannot catch, and which then -propagates into whatever is computed next. Downstream of one such call, line -items defaulted their price from the first product in the catalog, and -"is this deal already closed?" was answered against an unrelated record while -the write that followed correctly targeted the intended id. - -So a query that selects nothing in particular is now **refused** rather than -answered. Say which record you want in one of three ways: - -| Instead of | Write | Meaning | -|---|---|---| -| `findOne(o)` / `findOne(o, {})` / `findOne(o, { where: {} })` | `findOne(o, { where: … })` | the record matching this predicate | -| | `findOne(o, { search: 'Acme' })` | the record this search finds | -| | `findOne(o, { orderBy: [{ field: 'created_at', order: 'desc' }] })` | the FIRST record in this order — the newest | -| | `find(o, { limit: 1 })` | any row will genuinely do — and the call site says so | - -The error names all four. `find` and `count` are unchanged: returning or counting -every row is an honest answer, and only `findOne`'s implicit "just one of them" -turns a missing predicate into a confidently wrong record. - -Two silent drops that produced the same wrong record are fixed with it: - -- **`findOne({ search })` now applies the search.** The ADR-0061 `search` → - cross-field `$contains` expansion ran in `find` only, while both methods are - checked against the same legal-key set — so `search` passed the gate, reached a - driver that does not read it, and the read came back unpredicated. The - expansion is now one function both call, and a drift pin requires every option - `findOne` declares to have an observable effect. -- **`MongoDBDriver.findOne` now applies `orderBy`, `fields` and `offset`.** It - translated `where` and dropped the rest, so "the newest record" returned - whichever document the scan reached first. No ordering is imposed when the - caller supplies none (#4363) — that part is unchanged, on both drivers. - -Together with the `filter` → `where` fold on every entry point and the -unknown-key rejection (both already in this release), a read parameter the engine -does not execute now fails at the call site instead of quietly changing the -answer. - -#### Dead spec clusters removed - -**App shell (2026-06 liveness audit, #4001 app step).** `App.version`, -`App.aria`, `App.objects`, `App.apis`, `App.sharing`, `App.embed` and -`App.mobileNavigation` are tombstoned — none was ever read by framework or -objectui. `sharing`/`embed` were the dangerous pair: a declared public-access -surface no route enforced (the live path is `FormView.sharing`). -`mobileNavigation` was a mode picker that changed nothing. Each key rejects -with its prescription; `os migrate meta` deletes them from your source. - -Each of these parsed and did nothing. None has a runtime consumer; delete the -import or the authored key. - -| Removed | Note | -|---|---| -| `PortalSchema` | portal metadata was never enforced | -| `AuditConfig` cluster (`@objectstack/spec/system`) | dead since #1878 | -| Capabilities-descriptor cluster (`ObjectQL`/`ObjectUI`/`Kernel`/`ObjectStack`/`ObjectOS CapabilitiesSchema`) | static snapshots superseded by runtime discovery | -| `FeatureFlagSchema` (`kernel` feature.zod) | orphaned module | -| `DevPluginConfigSchema` cluster (`kernel` dev-plugin.zod — `DevServiceOverride` / `DevFixtureConfig` / `DevToolsConfig` / `DevPluginPreset`) | a declared dev-mode protocol (presets, fixtures, dev-tools dashboard, per-service mock/stub strategies, simulated latency) nothing implemented and no load path parsed — `@objectstack/plugin-dev` reads its own `DevPluginOptions`, and the `strategy: 'stub'` vocabulary described the dev-stub design ADR-0115 retired (#4149) | -| `SkillSchema.permissions` | never gated anything (#3686) | -| `tool.requiresConfirmation` | a safety flag nothing enforced (#3715) | -| `object.enable.trash` / `enable.mru` | ADR-0049 enforce-or-remove close-out (#2377) | -| `ReportColumnSchema` / `ReportGroupingSchema` + report chart `groupBy` | unread (#3463) | -| Report `aria` / `performance` props | report-liveness close-out | -| `DataQualityRulesSchema` / `ComputedFieldCacheSchema` | orphaned value schemas (#3726, #3733) | -| `DynamicLoadingConfig` / `PluginDiscoveryConfig` / `PluginDiscoverySource` | promised plugin sandboxing / integrity verification / source allow-listing / load approval; none of it was ever wired (#3950) | -| `RowLevelSecurityPolicy.priority` | conflict-resolution semantics that cannot exist under OR-combination (#3990) | -| `tool.category` / `.permissions` / `.active` / `.builtIn` (+ `ToolCategorySchema`) | authorable and inert — `permissions` gated nothing, `active: false` withdrew nothing; strict-rejected with prescriptions (#3896 close-out) | -| `action.shortcut` / `.bulkEnabled` | no keydown path dispatches shortcuts; the multi-select toolbar reads the view's `bulkActions` (#3896 close-out) | -| `flow.active` / `.template` / node `outputSchema` / `errorHandling.fallbackNodeId` | `active: false` never stopped a flow — `status` is the enforced lifecycle; faults route via per-node fault edges (#3896 close-out) | -| view: list `responsive`/`performance`, form `defaultSort`/`aria` | no renderer read any of them; list `aria`/`data` **and form `data`** stay live — `defineForm` writes `data: { provider: 'schema', schemaId }` onto every metadata form (#3896 close-out) | -| `dashboard.aria` / `.performance` / widget `performance` (+ `PerformanceConfigSchema`) | no renderer applied them; virtual scrolling is the live top-level `virtualScroll` (#3896 close-out) | -| `agent.knowledge` (+ `AIKnowledgeSchema`) | declaring sources/indexes never scoped retrieval — `search_knowledge` takes `sourceIds` from the LLM's tool-call arguments (#3896 close-out) | -| `PluginLifecycleSchema` (`onInstall`/`onEnable`/`onDisable`/`onUninstall`/`onUpgrade`) + `UpgradeContextSchema` + the `ObjectStackPlugin` interface family (`@objectstack/spec/system`) | a plugin lifecycle the kernel never implemented — the real contract is `init`/`start`/`destroy`; code written against the hooks silently never ran (#4212) | -| The typed-event cluster (`kernel` plugin-lifecycle-events.zod — ten payload schemas (`PluginRegisteredEvent`, `HookTriggeredEvent`, `KernelReadyEvent`, …), the 21-name `PluginLifecycleEventType` enum, `ITypedEventEmitter`) | a typed-event system that was never built: zero consumers, and the enum was wrong in both directions — 17 names nothing fires, 10 real events missing. `IPluginLifecycleEvents` is now the registry of the 14 events with a real emitter, and the new `LifecycleEventName` union soft-types `PluginContext.hook`/`trigger` (#4212 follow-up, #4241) | -| `skill.triggerPhrases` | phrases were never matched; activation is `triggerConditions` + the agent's `skills[]` allowlist (#3896 close-out) | -| `DEFAULT_DISPATCHER_ROUTES` | dead route table | -| Aspirational config on Theme / Translation / Webhook | still-dead after #3494 | -| `ChartInteraction.zoom` / `.clickAction` | never implemented (#3752) | -| The `workflow` service slot — `CoreServiceName 'workflow'`, `IWorkflowService`, `WorkflowProtocol`, the `Get/WorkflowState/Config/Transition` schema cluster, discovery `routes.workflow` / `services.workflow` / `features.workflow`, the `RestApiRouteCategory 'workflow'` member and the stray `graphql` provider entry | declared end to end and implemented nowhere: nothing ever registered or resolved the slot (ADR-0115 Evidence 5), no method of `WorkflowProtocol` was ever implemented, no host ever mounted `/api/v1/workflow`. State machines are `state_machine` validation rules; approvals are flow nodes (ADR-0019); record-triggered automation is hooks + `record_change` flows (#4451) | -| `datasource.readReplicas` | replica connections nothing ever opened — no driver reads the key and no query path splits reads from writes, so every statement went to the primary. #4410 had just taught the schema to validate each entry against the declared driver's contract, which made a dead slot look rigorously alive (#4468) | -| The per-provider connector "template" cluster (`@objectstack/spec/integration` — `DatabaseConnectorSchema`, `FileStorageConnectorSchema`, `GitHubConnectorSchema`, `MessageQueueConnectorSchema`, `SaasConnectorSchema`, `VercelConnectorSchema`, their ~100 sub-schema/type/example exports, and the six generated reference pages) | the losing side of a decided architecture fight, left standing: ADR-0023 rejected hand-modelling each external system's shape inside the spec, and the live ADR-0097 protocol gets provider shapes from the provider itself (connector-openapi / connector-mcp materialize at boot). Zero consumers — `engine.registerConnector()` validates against `ConnectorSchema` from `connector.zod.ts` alone, and nothing referenced the six files, not even their own module's live half. `DatabaseConnectorSchema` also declared read-replica routing a *second* time (`readReplicaConfig`, see the row above), down to a `weight` field for a load balancer that does not exist (#4480) | -| The `trigger-registry.zod.ts` Connector cluster (`@objectstack/spec/automation` — `ConnectorSchema`, `ConnectorInstanceSchema`, `ConnectorOperationSchema`, `ConnectorTriggerSchema`, the `Authentication*`/`OAuth2Config`/`Operation*` vocabulary, the `Connector.apiKey()`/`.oauth2()` factory helpers, and the generated reference page) | the *third* declaration of the same business need, and the file never contained what its name promises — no trigger registry, 630 lines of connector vocabulary with zero consumers. The automation engine registers connectors against `integration/connector.zod.ts` (ADR-0097) and the stack `connectors:` collection parses `DeclarativeConnectorEntrySchema`; nothing registered, validated or executed against this copy. Its header even carried a "When to use" comparison steering lightweight cases here — a signpost to a dead end, removed with it (#4499) | -| The `kernel` metadata-loader envelope family — `MetadataFormat`, `MetadataStats`, `MetadataLoadOptions`, `MetadataSaveOptions`, `MetadataExportOptions`, `MetadataImportOptions`, `MetadataLoadResult`, `MetadataSaveResult`, `MetadataWatchEvent`, `MetadataCollectionInfo`, `MetadataLoaderContract` (`@objectstack/spec/kernel`) | eleven names that each existed **twice**, with a different shape, on `./kernel` and `./system` — so which type you got depended on your import path. Every consumer imported the `./system` copy; the `./kernel` copies had zero consumers. Import them from `@objectstack/spec/system` (#4411, ADR-0049). `MetadataManagerConfig` / `MetadataFallbackStrategy` are unaffected and still ship from both entries | - -The Console side follows: `@object-ui/types` drops its -`ObjectStack`/`ObjectOS`/`ObjectQL`/`ObjectUI` Capabilities re-exports, which -pointed at the same retired cluster (objectui#2860). Import what you still need -from `@objectstack/spec` directly. - -#### Field widgets receive their metadata on one key, `field` (objectui#3233) - -`FieldWidgetComponentProps` no longer declares `schema`. The prop was a second -carrier for what `field` already means: `SchemaRenderer` passed the authored -node as `schema`, the form renderer's `renderFieldComponent` passed -`schema={props.field || props.schema || props}` *alongside* `field`, and about -thirty widgets settled the disagreement themselves with `field || schema` — one -concept, two spellings, a de-facto second contract. - -**Reading the metadata** — drop the fallback: - -```diff --const config = field || (props as any).schema; -+const config = field; -``` - -**Registering a widget** that can be rendered from a schema node — anything -`SchemaRenderer` dispatches, not just forms — wrap it once so it still receives -`field`: - -```diff -+import { withFieldCarrier } from '@object-ui/fields'; -+ --ComponentRegistry.register('color', ColorField, { namespace: 'field' }); -+ComponentRegistry.register('color', withFieldCarrier(ColorField), { namespace: 'field' }); -``` - -`withFieldCarrier` forwards the node **by reference** — nothing is copied, -narrowed or renamed — and consumes `schema` so it cannot reach the DOM through a -widget's `...props` spread. The SDUI node → `field` translation now happens -exactly once, in that adapter, and every built-in field widget is registered -through it. - -**Who is affected:** anyone who wrote a field widget. In TypeScript, reading -`props.schema` is now a compile error rather than a silent `any`; a third-party -widget that keeps reading it and is **not** re-registered through the adapter -reads `undefined` in 17 and renders its empty or default state without -complaining. That is the deliberate cost of a major boundary — one contract -beats N dialects, and picking the wrong spelling should fail at compile time -rather than work under one host and not another. - -**Host metadata is untouched.** No authored SDUI JSON changes — this is a change -to how widgets are *written*, not to what apps declare. `schema` also remains the -universal SDUI prop every registered component receives from `SchemaRenderer` -(`element:*`, `page:*`, grids, reports); only the *field-widget* contract retired -it. - -#### Flow node geometry is the spec's `FlowNode.position` (objectui#3172) - -The flow designer writes node coordinates as `position: { x, y }` — the key -`@objectstack/spec` has modelled all along — instead of its own `ui: { x, y }`. -Dragging, adding-at-a-point and insert-on-edge each wrote the local spelling; -all three now write `position`, and the canvas migrates on write: a stored -flow's legacy `ui` is lifted onto `position` and the key removed in the first -patch the canvas emits, geometry-related or not. - -**This is a behaviour fix, not a rename.** `FlowNodeSchema` has been `.strict()` -since #4001, so `ui` is an `unrecognized_keys` error: client validation flagged -the draft on every keystroke and the server rejected the save with a 422. In -other words, dragging a node made the flow unsavable — the convergence is what -makes the designer's most basic gesture round-trip again. - -**Who is affected:** anything reading `node.ui` off a flow draft. After the -author's first edit the key is gone and the coordinates live under -`node.position`: - -```diff --const { x, y } = node.ui; -+const { x, y } = node.position; -``` - -**Reading a stored flow stays backwards-compatible.** `manualPosition()` prefers -`position` and falls back to a legacy `ui`, so a flow saved before this change -still opens with its nodes exactly where the author left them. The fallback is a -migration path, not a second contract: nothing writes `ui`, and the canvas -strips it at its input boundary, so no patch can re-emit it. Nothing in this -repo or the engine ever read the key — it was designer-local, and the schema -rejected it — so the migration reaches only code written against the designer's -own drafts. - -#### Smaller breaking changes - -- **MongoDB driver declares itself single-tenant** and refuses to boot in a - multi-tenant configuration rather than silently mixing tenants (#3724). -- **Multi-organization operation is an entitlement again.** The `group` posture - no longer self-activates — it requires the enterprise runtime - (`@objectstack/organizations`). The first ADR-0105 wave made it - self-activating, which turned `group` into a free multi-org path around the - `isolated` gate and made the *weaker* isolation the free one. -- **`bootStack({ multiTenant: true })` now requests the `isolated` posture** - explicitly. -- **Kernel-built assignment notifications are dropped** from `plugin-audit`; - the policy moves to user-space automation (#3403). -- **`sys_view_definition`'s all-six `apiMethods` whitelist is dropped** (#3026). -- **`os migrate plan` shows index drift** — index DDL is no longer applied - silently at boot (#3728). -- **A flow's `errorHandling.strategy: 'retry'` must state `maxRetries` (>= 1)** - (#4247). `maxRetries` had two defaults — `.default(0)` in `FlowSchema` and - `?? 3` in the engine's `retryExecution` — so an unstated count retried 0 times - for a flow that had been through the schema and 3 times for a definition - handed to the engine directly. The engine's copy is gone (it reads the parsed - block with no fallback), and the case that was ambiguous is rejected rather - than guessed: retrying zero times is `strategy: 'fail'` under another name, - and a retry re-runs the *whole* flow, so the count is the author's to state. - Fix: write `{ strategy: 'retry', maxRetries: 3 }`, or `strategy: 'fail'` if no - retry was intended. `maxRetries: 0` stays legal under `'fail'` / `'continue'`, - which never read it. -- **`ObjectQLEngine.use()` and `ObjectQLHostContext` are removed** (#4212 - follow-up, #4242). The engine's own plugin loader — register a manifest - part, then dispatch the runtime part's `onEnable` over an - `ObjectQLHostContext` — had zero callers repo-wide, and its `onEnable` was - the engine-level twin of the #4212 disease: a lifecycle entry point that - reads as a contract and never runs. Fix: `engine.use(manifest)` → - `engine.registerApp(manifest)`; `engine.use(_, { onEnable })` → a kernel - plugin (`kernel.use({ name, init(ctx) { … } })`, engine via - `ctx.getService('objectql')`, drivers via `engine.registerDriver()`). - `new ObjectQL({ logger })` and `ObjectQLPlugin`'s `hostContext` option keep - working, and the *app-bundle* `onEnable` module export (dispatched by - AppPlugin at boot) is a different, real contract — unchanged. - -### New capabilities in 17.0.0 - -#### Files become platform records (ADR-0104) - -The headline of the line. `@objectstack/spec/data` now owns the **runtime value -shape** of every field type (`field-value.zod.ts`): semantic type classes, -`isMultiValueField`, and `valueSchemaFor(field, 'stored' | 'expanded')`. The -four consumers that each hand-copied this knowledge — the objectql record -validator, REST import coercion, driver-sql column classification, and the QA -conformance matrix — derive from the spec instead, and the field-zoo round-trip -matrix is asserted against the contract so they cannot drift. - -For media fields specifically: - -- **The stored form narrows to an opaque `sys_file` id.** The inline - `{url, name, size, …}` blob becomes the `'expanded'` read form, which still - admits an unresolved id exactly as an unexpanded lookup id stays valid. -- **`accept` and `maxSize` are declarable on `FieldSchema` and enforced on the - server.** Both were already read by the upload widgets while the spec did not - declare them, so authoring them meant a silently stripped key and a constraint - that never existed. Because the platform now owns the file, `sys_file` carries - the authoritative MIME type and byte size, so a record write is re-checked - where it binds — a browser-side check is a convenience, not a control. - Violations raise `FileConstraintError`. An entry is judged only against - metadata the file actually reports: "we don't know" never becomes - "not permitted". -- **Exclusive field-reference ownership**, a **governed download** path for - field-owned files, and an object's ability to **delegate file-read - authorization to its service** replace the unguessable-URL model. Downloads - carry the real filename and content type instead of the URL token, and - `_local/file/:key` — a URL nothing mounted — is gone. -- **Two legacy forms stop conforming, deliberately:** the inline blob (no longer - stored, now derived) and the external URL (never a managed file — it retires - toward an explicit `url` field, so "managed file" and "external link" stop - being the same declaration). - -**Value-shape checking follows your own migration, not the version number.** A -not-yet-backfilled row still writes, and the author gets a warning naming the -field. Media fields start *rejecting* malformed values only once **this -deployment** has run the migration and passed its self-check: - -```bash -os migrate files-to-references # dry run: reports, writes nothing -os migrate files-to-references --apply # converts, verifies, records the flag -``` - -The run backfills legacy file-field values (inline metadata blobs, own-resolver -URLs, `data:` URIs) into owned `sys_file` references and reconciles the ownership -ledger against what records actually hold. The **deployment-level flag it -records** — never the platform version — is what authorises both strict media -value shapes and irreversible file collection. Upgrading changes neither; -running the migration does. - -**The non-media classes have their own gate** (#3438) — references (`lookup`, -`master_detail`, `user`, `tree`) and structured JSON (`location`, `address`, -`composite`, `repeater`, `record`, `vector`): - -```bash -os migrate value-shapes # scan: reports, writes nothing -os migrate value-shapes --apply # scan, then record the flag if clean -``` - -A separate flag because it attests a separate fact: the file migration says file -values were converted and their ownership reconciled, which tells you nothing -about whether a `lookup` id or a `location` payload is well formed. This one -converts nothing — a malformed `location` is application data only its author -can correct — so it reports the object, field, type, count, sample record ids -and parse issue, and you re-run until it is clean. `OS_ALLOW_LAX_VALUE_SHAPES=1` -re-opens leniency while diagnosing. - -**You are told about both, rather than having to find this page.** `os migrate -meta` — the command a 16→17 upgrade already runs — ends by naming the two data -migrations and the gate each records, including on a run that rewrote nothing, -since canonical metadata says nothing about stored values. A deployment that -boots holding covered fields with no verified gate row logs one line naming the -command that ends warn mode. Neither reads as "done": until a gate is recorded, -the classes it covers keep warning. - -That boot line is scoped to the deployments it can actually help. It counts only -fields a write would check — not the `lookup`s the registry injects into every -object — and says nothing once an environment switch has already settled the -posture, since `OS_DATA_VALUE_SHAPE_STRICT_ENABLED` enforces both classes -outright and either `OS_ALLOW_LAX_*` opt-out is a deliberate choice the scan -would not change. - -**A database created by 17 attests both flags at creation** (#3438), so a new -deployment enforces from its first boot instead of waiting for someone to run a -migration that, for an empty store, does nothing. The platform attests only a -store it watched itself create — every table made by that boot, none found -already there; an upgraded or restored database attests nothing and produces its -evidence by running the command. - -**Released-file collection is live behind that same flag** (#3459). On a -verified deployment, a field file whose one owning record lets go — the field -cleared, or the record deleted — is tombstoned into the declared 30-day grace -window; re-referencing the id inside the window revives it, and past it the -platform sweep re-verifies at delete time that nothing holds the file (join -rows, ownership columns, and a fresh read of the flag itself) before -reclaiming the row and its bytes. A deployment that never migrates keeps every -released file forever: upgrading is not consent — passing your own -migration's self-check is. - -Two knobs sit either side of that flag on the value-shape half. -`OS_ALLOW_LAX_MEDIA_VALUES=1` returns a verified deployment to warnings, for an -operator who hits an unforeseen rejection and needs writes flowing while they -diagnose. `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1` goes the other way and opts -*every* value class into strict immediately — including the reference -(`lookup`/`user`/…) and structured-JSON (`location`/`address`/…) types, whose -own per-deployment gate is still being built (#3438). Those stay warn-only by -default until it lands, because unlike media they have no migration standing -behind them yet. - -#### Approvals: dynamic approver routing (#3447) - -- **`expression` approvers.** A CEL expression resolves *who* approves at node - entry, over exactly three roots: `current.*` (the record's live state), - `trigger.*` (the submit-time snapshot) and `vars.*` (flow variables, including - upstream node outputs). Bare `record` and bare field names are rejected before - evaluation — on this platform `record` always means "the record at event - time", which is ambiguous at an approval node — with error messages that - prescribe the correct spelling. Optional - `resolveAs: 'user' | 'department' | 'position' | 'team'` re-expands each - resolved id through the same graph lookups the static types use; with - `behavior: 'per_group'` each intermediate value forms its own sign-off group. -- **`onEmptyApprovers` policy**, node-level, for every approver type: - `admin_rescue` (default — the request opens for privileged takeover), - `fail`, or `auto_approve` (skip the request and continue down the `approve` - edge with `output.autoApproved = true`). -- **Decision outputs.** The author declares allowed keys on the node - (`decisionOutputs`); approvers fill values only; accepted outputs resume the - run as `.` variables, so a later node's expression can read - `vars..picked_departments`. Undeclared keys reject the decision; - `decision` and `requestId` are reserved. A `decisionOutputs` entry may be typed - (`{ key, label?, type: 'text'|'user'|'department'|'position'|'team', multiple? }`) - to make the decision UI render a record picker instead of free text. -- **`field` / `manager` approvers resolve against the record's live state** at - node entry rather than the trigger snapshot the flow froze at submit time, so - an earlier step can write the field that routes a later step's approvers. - Graph approvers already resolved live; this brings the in-record types into - line. -- **Approver value bindings are declared.** `APPROVER_VALUE_BINDINGS` is the - single declaration of how a designer sources each approver row's `value`. - `queue` is deprecated for authoring — it still parses so stored flows keep - loading, but it is published in `xEnumDeprecated` because the runtime has no - queue resolution and the slot routed to nobody. -- Also: cross-organization approver targeting, `department` approvers resolving - against env-wide business units, per-group membership of pending approvers, - inbox rows enriched with snapshot field labels (`payload_labels`), the pending - node's `lockRecord` policy exposed on the request row, decision attachments - returned as real file values, an admin override for requests routed to an - unstaffed approver, a status mirror that names the human who caused the - transition, and a decision recorded against the authenticated caller rather - than a body field. - -#### The SDK reaches the whole REST surface (#3563, #3587) - -Beyond the deletions above, the client gains typed access to everything the -server actually mounts: the `actions` surface, `keys` / `shareLinks` / -`security`, the eleven package-lifecycle methods, metadata drafts/published/FSM, -automation descriptors, the reports family, approvals and record shares, sharing -rules, security-explain, and search. `automation.resume()` / -`automation.getScreen()` finish a paused screen flow from the SDK. - -The ledger is the point: a **route-ledger conformance guard** runs in both -directions — every SDK URL must match a route some surface mounts, and every -mounted route must be reachable or explicitly ledgered — with the REST surface, -the dispatcher and the autonomously-mounted service routes each carrying their -own ledger. `analytics.meta` / `analytics.explain` and two i18n calls that -reached nothing are repaired by the same audit. - -#### Write observability — a silent strip stops reading as a clean save - -`PATCH`/`POST /data` surface **`droppedFields`** when the server silently strips -a write, extended to the bulk paths, the cross-object batch, and the client SDK; -flows surface silently-stripped write fields as step warnings; and a batch -create now goes through the same create ingress as a single create. `os validate` -runs the four authoring lints `os build` runs, so "validate clean, build fails" -is gone. - -#### Analytics correctness - -- `ObjectQLStrategy` **enforces the read scope** (RLS + tenant), and the - read-scope auto-bridge no longer depends on plugin order. -- `timeDimensions[].dateRange` is applied — the predicate every date-bucketed - chart was missing. -- The **effective** date granularity drives bucket labels and drill ranges, and - widget `dateGranularity` / `sortBy` / `sortOrder` / `limit` are honoured in the - dataset query. -- Cross-object grouping is served in-envelope on the ObjectQL path by FK-expand, - and **fails closed** when the path cannot join. -- Dimension-label lookups are scoped to the referenced object's RLS, and dataset - selections sort by display label for select/lookup dimensions. -- Cube auto-inference is gated on object existence, and the dispatcher boundary - stops returning raw SQL. - -#### Automation & flows - -- **Every terminal run reports what it did** (#4354): `selected` / `acted` / - `skipped` totals, a per-node breakdown, and *which gate closed* — on the run - result, in `listRuns` / `getRun`, as one greppable log line - (`selected=30 acted=0 skipped=30 gate=check_stalled->send_nudge:30`), and as - queryable `selected_count` / `acted_count` / `skipped_count` columns on - `sys_automation_run`. `success: true` never meant "it did its job": a sweep - that selected thirty records and wrote none was indistinguishable from one - with nothing to do, which is how three inert production flows ran green for - as long as they had existed. `selected > 0 && acted == 0` over consecutive - runs is now the detector. See - [Run summaries](/docs/automation/flows#run-summaries). -- **`record-after-write`** fires one flow on create **or** update (#3427), with - `previous` bound as `null` on the create leg so start conditions can - discriminate. -- Opt-in single-hop **lookup expansion** for record-change flow templates. -- The **resume gate** is one chokepoint: it follows `map:` too, is gated by the - node the run is parked on, and the route stops accepting engine-internal - variables. -- A `fault` edge must not switch off a guardrail; refuse-to-execute guards lose - their default-routable footgun; a filter that loses a condition must not run; - array-form `triggerType` fails loudly instead of silently never firing; string - templates serialize object tokens readably instead of `[object Object]`. -- `retryPolicy` / `timeout` authored on a job are honoured by the scheduler. -- `{filter-token}` placeholders evaluate server-side, and the - `{current_user_id}` vocabulary is frozen with unresolvable placeholders - failing the build. - -#### Historical data import - -`treatAsHistorical` skips the state machine for historical-data migration **and** -preserves the original audit timeline — including on undo. Row errors are -sanitized so a constraint failure reads as human wording instead of leaking raw -SQL. - -#### Lint & CLI - -New and widened rules: reference-integrity validation for object and action -names, translation-bundle reference integrity and option-key validation, -never-firing record trigger tokens, flow `update_record` writes to readonly -fields, replay-unsafe `mode: 'insert'` seed datasets, seed values outside a -declared state machine, `label: 'error'` written where `type: 'fault'` was meant, -AI surface affinity (skill ↔ agent), the ADR-0109 platform tool-name registry -with an advisory `skill.tools[]` reference lint, and expression/empty-slate/ -reserved-output-key gates for the new approver capabilities. Filter references -and flow template paths that cannot resolve now fail the **build**, not the run. - -On the CLI: `os i18n extract --check` fails instead of writing when bundles have -drifted, `--json` truncation is fixed across every command, the startup banner -reads a DSN-declared datasource and stops printing credentials, and the boot -banner reports seed outcomes (`Seeds: X inserted · Y updated · Z skipped`, -escalating to a yellow `⚠ … N REJECTED` line) so a fixture can no longer lose -most of its rows in silence. - -#### Spec, kernel & platform - -- **`ISecurityService` is published** — the `security` service surface becomes - an enforced contract, with `security.getReadableFields` for export column - projection. -- **API-method derivation is single-sourced**: the server is the only - adjudicator, and the exposure gate's metadata fail-open is observable. -- The **HTTP dispatcher is decomposed** into per-domain modules behind a thin - handler registry (ADR-0076 D11) — auth, ai, automation, packages, share-links, - keys, storage, ui, actions, mcp, meta, data — and request→environment - resolution unifies on the host's `kernel-resolver` seam. -- An action **rejects a `body` on a non-script action** and **rejects unknown - keys on an action param** instead of stripping them; an inline `lookup` param - can declare its reference target. -- `ListColumn` gains `prefix` and the `{ type, field }` `summary` form; page - metadata i18n resolves `page:header` title/subtitle; the filter logical - combinators get one canonical conformance table; `IHttpServer` soft extensions - and unmatched-request semantics are codified. -- Liveness entries gain a `verifiedAt` re-verification clock, and a batch of - ledger claims were re-verified against the real Console consumer — eight of the - last ten preview-only `live` claims were wrong. -- **The ledger's security subset had its first full re-verification** (#3896 - follow-up): all 44 permission/position/object-sharing entries call-graph-closed - by hand and dated. It found the unenforced RLS `enabled` (fixed the same day) - and the void `priority` (removed), refuted two standing suspicions - (`allowExport` *is* enforced server-side; the transfer/restore/purge gates are - pre-mapped fail-closed), and bound two new runtime proofs - (`permission.tabPermissions`, `permission.objects.writeScope`) so the claims - re-prove on every CI run. -- **A new `check:empty-state` gate** scans the authorable surface — spec schemas - *and* platform-object field descriptions, where #3896's *"leave empty to share - every record"* actually lived — for statements declaring a permissive empty - state, and requires each to be classified (`scope` / `closed` / `open` / - `output`) with a rationale. Omission is the commonest authoring error a model - makes; it must not also be the widest grant. -- **File-backed SQLite runs in WAL mode** (#3941). SQLite's built-in rollback - journal makes a writer wait for every reader and leaves an idle connection - invisible to SQL — both wrong for the platform's normal shape, several - processes on one file (a dev server, `os migrate`, a test run). The driver now - switches such a database to WAL on connect, which is also what lets the - `os migrate` occupancy check *see* an idle server instead of inferring one from - file descriptors. Two things to know: `app.db-wal` / `app.db-shm` appear beside - the database while a connection is attached (so back up with - `sqlite3 … ".backup"`, never a bare file copy), and WAL cannot work on a - **network filesystem** — set `OS_DATABASE_SQLITE_JOURNAL_MODE=delete` there, - which converts an already-switched database back. -- Metadata-plane FLS (per-caller masking) is proposed as ADR-0106. - -### New in Console (Studio) — bundled objectui 17.0 - -This section covers the window bundled at **`rc.0`**: `cf2d56e32a11 → -4a4829d0ef39` (`.objectui-sha`) — 128 objectui commits on top of the pin -16.1.0 shipped. The pin has since advanced — the rest of the line is under -*New in Console* in the **Landed since 17.0.0-rc.0** section below. - -#### Files, actions and forms - -- **The file-as-reference value shape is adopted** end-to-end (ADR-0104 D3 - wave 2), including a localized FileField upload widget. -- One precedence for action `target`/`execute`, and server-side `body` stops - being mislabeled; a modal action's `target` resolves as a **page**, not an - object; the spec's `disabled` predicate is honored on every action-rendering - surface; inline `lookup` action params get a real record picker. -- Forms consume spec-aligned `FormView` buttons/defaults, and an invalid submit - scrolls to and focuses the first errored field; the flow-node repeater stops - committing during render. -- Image fields render consistently and support click-to-zoom. - -#### Approvals - -- **Typed output pickers, dynamic decision-output fields, expression approver - editing**, quick-path guard and expression completion — the Console half of - #3447 P2. -- Approval Center triage, density and drawer readability passes; pending-approver - chips labelled with their group; the admin override for a stuck request - surfaced in the inbox; the timeline attachment chip shows its name and opens; - the detail band **honors the node's `lockRecord`** instead of assuming every - approval locks, and distinguishes "in approval (editable)" from locked. -- The inbox renders against one ticking clock. - -#### Data, grids and charts - -- The grid computes **all eleven** spec column summary aggregations, gates row - Edit/Delete and bulk delete on the **effective operation set**, and shows the - real match total under server pagination. -- `` honors the spec `ChartConfig` author shape, its aggregate - result-column naming is a contract, and its axis bindings are validated — - a fieldless `count` aggregate no longer keys its value column `undefined`. - `ChartAxis.stepSize`, `ChartConfig.description` and `.height` are honored. -- Dashboards send widget query options to the server and order funnel stages by - the pipeline; Kanban surfaces off-column records in an Uncategorized lane. -- A toast fires when a save silently dropped read-only fields, and write - warnings stop being lost on the detail page. -- Real per-caller **FLS** is wired into import targets and grid columns, and the - Import Wizard gains an **"Import as historical data"** option; the import - preview validates email format up front. -- **Detail and form edit/delete are gated on the server's effective operation - set** (#3546), the same adjudication the grid rows use — the Console stops - offering a verb the server would refuse. -- Dashboard and chart widget filters resolve `{current_user_id}` (#3574). -- The five per-view-type configs and `ListView` read the spec-canonical - vocabulary (`filter`, `$notContains`, type/label/maxLength keys), and `secret` - stays out of inline edit. - -#### Flows, Studio and Setup - -- A paused **screen flow is completable**: `visibleWhen` is honored in render and - validation, flow actions dispatch from every surface, and the runner stops - tearing down its host. -- Studio gains a first-class **notify** flow node, a "Record created or updated" - start trigger, an `enable.searchable` toggle in the object settings panel, and - step warnings in the Flow Runs panel; the never-firing `record-change` option - is removed from the trigger picker. -- **Setup's datasource list shows the real connect verdict** with the - operator-facing reason; the sharing-rule dialog becomes usable (i18n, a picker - that lists people, permission-aware CTAs); `delegated_admin` is reachable and - both of its pickers are narrowed; scoped-invitation placement invites straight - into a unit and its positions; the flow designer reads approver value sources - off the schema, and approver values become **record lookups** (#3508). -- **Group tenancy posture affordances** (ADR-0105 Phase 1): the org switcher - becomes the write context, and records carry org attribution. -- The API console lists the whole **AI family** and the routes that exist, and - the tool preview stops linking to a 404. - -#### Internationalization & quality - -- **The locale backfill completes: all ten packs reach full key parity.** The - four highest-traffic namespaces are translated into the eight trailing locales, - hand-rolled zh/en branches and `pick({en,zh})` clones are retired, and `en` - becomes the complete source of truth for grid import and set-password. The - system-settings hub is localized. -- ESLint runs on PRs across every package, and the last five unchecked packages - are type-checked — which surfaced two runtime bugs hiding there. -- Console build dependencies take three major bumps — `maplibre-gl` 5→6 (the - `plugin-map` default import is dropped to match), `chalk` 5→6, and `jsdom` - 29→30 (dev) — relevant if you build the Console from source. - -### Landed since 17.0.0-rc.0 - -These changes are on `main` after the `rc.0` cut and **roll into 17.0.0-rc.1** — -backend from the pending changesets (334 at the time of writing, 40 of them -`major`-class), Console from the objectui pin advancing -`4a4829d0ef39 → 7d9734d5e321` (79 commits, released as **objectui 17.1.0**). -Sourced from each repo's own changesets. - -One reading note: this page documents the whole 17.0.0 train and has been kept -current as the train moved, so the *largest* post-rc.0 landings are already -documented in place in the sections above rather than repeated here: - -- **ADR-0110 action-declaration admission** (#3935) — including its post-rc.0 - revision: the `OS_ALLOW_UNDECLARED_ACTIONS` valve was **removed before 17 - ships**; the refusal has no opt-out. -- **ADR-0104 D2 action params strict by default** (#3438) — the - `OS_ACTION_PARAMS_STRICT_ENABLED` opt-in era ends; - `OS_ALLOW_LAX_ACTION_PARAMS=1` is the escape hatch — plus the D1 evidence - gates (`os migrate value-shapes`, fresh-datastore attestation, released-file - collection behind the verified file migration). -- **ADR-0113** — `required` becomes the write contract; `storage.notNull` owns - the column. -- **ADR-0114** — the closed field-error catalog and the `fieldErrors` → - `fields` rename (#3977). -- **The #4001 unknown-key strictness campaign's later clicks** — permission - sets + flows, RLS / sharing rules / positions, approval configs, hooks + - datasources, and the app shell & navigation tree, all `.strict()` with - self-fixing errors. -- **The seven protocol-17 retirements** — `api.requireAuth` (#3963), the wait - `timeoutMs`/`onTimeout` pair (#4158), `query.joins`/`windowFunctions` - (#4286), the `fields[]` object form (#4196), `query.cursor` / `distinct` - (#4286), `BatchOptions.validateOnly` (#4052) — and #4350's correction - re-labelling them protocol **17** (their tombstones said "18", so - `os migrate meta` was stepping over the two stack conversions and the - generated upgrade guide omitted all seven). -- **The #4212 retirement family** — the plugin lifecycle hooks, the typed-event - cluster, `ObjectQLEngine.use()`, the Dev Mode Plugin Protocol (#4149). -- **The #3896 security sweep's enforcement half** — criteria-less sharing - rules share nothing, RLS `enabled` is enforced, RLS `priority` and the four - inert tool keys are removed. -- **Flow `errorHandling.maxRetries` has one default** (#4247), **per-run flow - summaries** (#4354), and **file-backed SQLite in WAL mode** (#3941). - -What follows is the rest of the window: first the metadata- and -protocol-facing changes an upgrading application developer must read, then the -platform capabilities an administrator gains, then the Console delta. - -#### Breaking / behavior — protocol & wire - -- **The actions route speaks HTTP (#3962, #3951, #3937, #3913).** The - accidental 200-with-inner-envelope double wrap is gone; the contract is now - identical to `/data`: ran-and-returned = **200** `{success: true, data: - }`, single wrap; ran-and-rejected (business rule / - validation) = **400** with `error.details.code: 'VALIDATION_FAILED' | - 'FLOW_FAILED'` and per-field `fields[]`; not-dispatched = a real 404/403/503 - (an unknown action used to come back `{"success":true,"data":{"success": - false}}`); crashed (`TypeError`, driver class, sandbox timeout) = **500**, - visible to gateway error rates and APM instead of arriving as a green 200. - Five defects traced to the extra layer, including the Console's green toast - on failed actions and `redirectUrl` never firing. Object-less actions - canonically key on `'global'` and the `POST /actions//:action` shape is - mounted. `client.actions.*` callers need no change — the SDK still never - throws, and folds every failure into `{success: false, error}`. -- **`POST /actions/:object/:action` dispatches on the declared `type` - (#3915).** A `flow` action executes via the automation service **as the - caller** — `userId` / positions / permissions / tenant forwarded, so a - `runAs: 'user'` flow enforces RLS as the invoker instead of the user-less - UNSCOPED path — and the params bag is seeded with `recordId` (+ the declared - `recordIdParam`); `url`/`modal`/`form`/`api` actions answer 400 naming what - to call instead of a registry miss. Standalone `defineAction` artifacts and - Studio-authored action rows now resolve on REST with their - `requiredPermissions` enforced (previously MCP-only). Script action bodies - run with a bound, attributed `ctx.api` — owner-scoped writes stop dying - `FORBIDDEN`, and body writes are stamped with the caller and tenant and - join the open transaction. -- **A list query either applies or fails (#4121, #4134, #4164, #4181, #4226, - #4254, #4256).** Every input of the list/read surface now either takes - effect or answers 400 — nothing is silently dropped: a `?filter=` that does - not parse (or parses to a non-filter) is `400 INVALID_FILTER` — it used to - return the **unfiltered** table as a clean 200; unknown fields in `sort` / - `select` / `expand` / `searchFields` / `groupBy` / `aggregations` are - `400 INVALID_SORT` / `INVALID_FIELD` (a typo'd `select` returned **every** - column against FLS and data minimisation; an unknown `groupBy` collapsed N - groups into one; `sum()` folded to `0` — indistinguishable from a real - zero in a report); an unknown bare query parameter is `400 INVALID_FIELD` - with the canonical suggestion (`?pageSize=5` used to become a zero-matching - filter answering `total: 0`); a dotted-path sort (`?sort=account.name`) is - rejected with the denormalisation prescription; explicit `filter` + bare - field parameters compose with `$and` instead of dropping the implicit half; - and two sort spellings that silently never applied — the SDK's declared - `orderBy: string[]` and the `{field: direction}` map — now actually sort. - Export honours the searched view (`search=` / `searchFields=`, #4181). -- **Unsorted paged reads are deterministic (#4363, #3821).** A `limit`/`offset` - read with no `orderBy` — the shape every list view without a configured sort - sends — is now ordered by the unique key on SQL and MongoDB, and any - non-empty `orderBy` gains a unique tie-breaker, so page 2 can no longer - repeat a row while another row is never served. **The background walks seek - instead of counting** for the same reason: seven scans paged with a growing - `offset` while writing to the rows they were reading, so rows slid past the - cursor unvisited — which meant `rebuildApproverIndex` **deleted** index rows - for requests it skipped (an approver silently dropped from someone's queue), - `verifyFileReferences` reported referenced files as unreferenced, the file - and pinyin backfills left rows unconverted under a run that reported - success, and `scanValueShapes` — which opens the value-shape migration gate - on its evidence — could vouch for rows it never read. -- **`SqlDriver.findOne(object, id)` is removed.** An undeclared bare-id branch - that was on no contract, that nothing outside the driver's own tests used, - and that the other two drivers answered differently. Pass a query. In the - same pass `bypassTenantAudit` becomes a **declared** `DriverOptions` member - (it was live but read through a cast, so no caller was type-checked), with - its limit stated: it silences a diagnostic and must not change which rows a - write touches. -- **Query options fold once, everywhere (#3795, #4346, #4371).** The five RPC - aliases (`filter`→`where`, `select`→`fields`, `sort`→`orderBy`, - `skip`→`offset`, `populate`→`expand`) resolve through one spec-owned fold - with one precedence — four of five pairs were inverted between readers, so - `?select=a&fields=b` answered `[a]` on one path and `[b]` on the other. The - fold now covers **every** engine method: `findOne` / `count` / `update` / - `delete` / `aggregate` with `{filter}` used to match **every row** — - `update(data, {filter, multi: true})` rewrote the whole table and - `delete({filter, multi: true})` emptied it. Conflicting spellings - (`{where: X, filter: Y}`, `{top: 1, limit: 3}`) are refused naming both; - direct engine callers passing wire-only spellings or unknown option keys now - get an error naming the canonical key instead of silence (#4371) — which - also fixed queue purge, which passed a key the engine never read and so - deleted nothing. -- **`query.having` is enforced (#4286 follow-up).** Declared since AST v2 and - executed by nothing, `having` now filters aggregated rows on both the - native-driver path and the in-memory fallback, with `$and`/`$or`/`$not` and - loud rejection of unknown operators. A query that carried it was silently - returning every group. -- **Request bodies validate at the entry (#3899, #3878).** Catalog-declared - `requestSchema`s are enforced with `400 VALIDATION_FAILED` + `fields[]`: a - garbage `POST /data/:object/query` body used to degrade into an unfiltered - full read, `POST /automation/:name/toggle {"enable": false}` used to - **enable** the flow and answer 200, and a misnamed `POST /notifications/read` - key was a 200 no-op. `/analytics/query` and `/analytics/sql` validate the - **bare `AnalyticsQuery`** at the entry — the `{cube, query, format}` - envelope dialect of the retired shim is tombstoned, and the filter field is - `where`, not `filters` — instead of dying downstream as a 500 SQL syntax - error. -- **Bulk writes are bounded and bound to the path (#3939, #3933, #3897, - #3946).** The declared 200-row batch cap is now real on all five bulk routes - (`400 BATCH_TOO_LARGE`, governed by `batch.maxBatchSize`, 1..1000); a body - `object` can no longer move a bulk write or a `/data/:object/query` read to - a different object than the gated URL object; `deleteMany` builds its - predicate from the validated id list only — caller `options.where` used to - widen a one-id request into deleting everything the caller could see — and - per-id deletes now run `deleteBehavior` cascades and RLS/FLS under the - caller (its response becomes the structured `BatchUpdateResponse`); and a - caller-supplied `context` is dropped at ingress on these paths (#3960 — it - could previously become the execution context, `{isSystem: true}` included, - where no server context resolved). -- **Response envelopes converge (#3843, #3983, #4038, #4053, #4224).** The - settings, datasource-admin, external-datasource, package and share-link - route modules emit the declared envelope — payload under `data`, errors as - structured `{code, message}` on the ADR-0112 ledger — which un-broke two - shipped SDK methods (`client.shareLinks.create()` / `.list()`); the - dispatcher's duplicate top-level payload keys are gone; `GET /ai/agents` - (the last unenveloped SDK route) conforms; `/api/settings/*` error extras - move under `error.details`, with `SETTINGS_VALIDATION.fields` becoming - ADR-0114 `FieldError[]`. Raw-`fetch` callers of these routes add one `.data` - hop and read `error.code`; SDK callers are shielded — and the client now - normalizes both server envelope families, so `err.code` is always the - semantic string, never the HTTP status number (#3918). -- **Field validation speaks the caller's language (#3957).** Built-in - field-validation messages render in the request's locale (en, zh-CN, ja-JP, - es-ES — `Accept-Language` / `?locale`, falling back to the workspace - locale), name the field by its translated label, and carry a structured - `constraint` (`min`/`max`/`actual`/…). Match on `code` and `constraint`, - not English message text; any built-in is overridable per deployment via - `validation.field.` translation keys. -- **An absent capability answers honestly (#4093, #4113, #3891, #4087, - ADR-0115).** The last fabricating fallbacks are deleted. A mounted domain - with no implementation answers **501 naming the package to install** - (`/automation`, `/notifications`, `/ui/*`, `/ai/*` — previously misleading - 404/503s that sent operators chasing phantom routing bugs). `/auth/*` - without an auth service answers 501 instead of a **200 carrying a fabricated - session for any email and any password** — the mock shipped in production - `@objectstack/runtime` and told clients the one thing a server must never - lie about. The degraded analytics shim — which dropped the caller's - ExecutionContext, so authenticated callers got **aggregates over rows RLS - would hide**, and read a non-contract `filters` key — is removed: without - `@objectstack/service-analytics` the routes are not mounted (404), and - discovery reports `analytics: unavailable` instead of hardcoding it always-on - (#3989, #4010). The dispatcher's dead `/storage` bridge (which could never - complete a request, and whose wildcard shadowed the real presigned - `service-storage` routes) is removed. DevPlugin's stub table is retired — - an empty slot in dev behaves exactly as in production, and the allow-all - permissions / no-row-filter RLS / unmasked-field dev fakes now **fail - closed** like production does. Discovery computes every slot's verdict from - the registered service's own `__serviceInfo` (the `_dev: true` marker is - retired), its remedy strings name real packages (ten of fifteen previously - named packages that do not exist), and a slot self-declaring - `handlerReady: false` is answered like an empty slot instead of having its - fabricated 200 served (#4058, #4089, #4130). -- **`HonoServerPlugin` is a transport adapter (#4073).** Its raw data-C+R - surface, its third (pre-`DiscoverySchema`) discovery payload and the - `registerStandardEndpoints` flag are deleted — data and discovery each have - exactly one owner (`@objectstack/rest`; the runtime dispatcher). Every - composed host (`os serve`, `objectstack dev`, cloud) already answered - byte-identically. The three current-user endpoints (`/auth/me/permissions`, - `/auth/me/localization`, `/me/apps`) register unconditionally — no longer - hostage to the flag — yield correctly around the auth wildcard in either - registration order (#4088, #4117), and resolve per request through the - multi-tenant `kernel-resolver` seam, so authenticated tenant callers on a - routing host stop being told `{authenticated: false}` (cloud#927). -- **The memory driver is pure in-memory again (#4065, #4083).** A bare - `new InMemoryDriver()` — and a declared `driver: 'memory'` datasource — - silently wrote `.objectstack/data/memory-driver.json` into the working - directory and reloaded it on the next boot; the `persistence: 'auto'` - default drifted from the accepted opt-in design (#815) and is now `false`. - Durability is explicit (`{ persistence: 'file' }`, per-datasource - destinations), and the driver's "production-ready" claim is trimmed: it - stores no constraints — use in-memory SQLite when constraints matter. - `os init` scaffolds stop naming `driver-memory` as a dependency. -- **The `artifact-api` metadata source is removed (#4246).** Zero consumers - existed and half its URL contract had been dead since v5.0. - `{ mode: 'local-file', path }` is the single artifact source (it accepts the - public/commit-pinned cloud URLs); a still-configured `artifact-api` throws - loudly at `start()` instead of silently falling through to filesystem - scanning. -- **Smaller wire corrections:** the plural `/meta/:type` spellings no longer - skip the book-audience / app-RBAC / dashboard gates the singular spellings - enforced (#3984); `book.audience: 'public'` genuinely serves anonymously on - secure deployments (#3963 step 2) — review books that declare it, they are - now really public; three orphan operator vocabularies with zero importers - leave the spec (objectui#2945 Track A); `DriverPlugin`'s inert - `{datasourceName, registerAsDefault}` options are retired (#4320); the - legacy `_dev: true` service marker is retired in favour of `__serviceInfo`; - and exported spec types stop resolving to `any` (`NavigationItem`, - `FormField`, `QueryAST['fields']`, the `z.input` sides of the recursive - schemas) — authoring code whose invalid shapes previously compiled clean - now fails `tsc` with the real error, gated by the new `check:exported-any` - (#4171, #4195, #4221). - -#### Breaking / behavior — metadata authoring & runtime - -- **Flow-node config is enforced end to end (#4277, #4045, #4027, #4347, - #4389).** The twelve contract-carrying builtins `parse()` their `config` - before executing — a violation refuses the node as a guard naming every - violated path; `registerFlow` **rejects** undeclared config keys (exact - path, declared key set, did-you-mean, per-key tombstones like - `screen.visibleIf` → `visibleWhen`) and warns on keys a `configSchema` does - not declare; declared bare-CEL slots are validated - (`screen.fields[].visibleWhen` authored as `'{var} == true'` shipped a - forever-paused run); and ADR-0031 regions (`loop.body`, - `parallel.branches[]`, `try_catch.try/catch`) are walked by conversions, - validators and lint alike, so nested nodes stop escaping every check. A - legacy `{var}` condition carrying an unresolved dotted reference now - refuses instead of comparing strings — `'oppRecord.amount > 500000'` was - constantly true: a gate that never gated. -- **Flow-node aliases graduate to conversions (ADR-0087 D2).** `subflow` and - `map` `config.flow` → `flowName` (#4278, #4045), `notify`'s nested - `source: {object, id}` → `sourceObject`/`sourceId`, and `connector_action`'s - mis-rooted config lifts onto `connectorConfig` — Studio-authored connector - nodes never dispatched; stored flows are healed at load. All protocol-17 - windows applied by `os migrate meta` and at every rehydration seam, - alongside the seven #3796 aliases documented above. -- **A blank hook target is refused.** `object: ''` / `[]` silently registered - the hook on **every object in the tenant**; a wildcard must now be the - visible `'*'`, and `bindHooksToEngine`'s `strict` option actually fails - fast. `defineHook()` lands so convention-scanned hook files get the same - parse-at-import treatment as the rest of the define family (#4269). -- **Stored metadata replays the conversion chain (#3903).** Studio- and - API-written rows at rest go through the same ADR-0087 machinery as authored - source at every read seam (including `registerFlow` rehydration) — a stored - action with the removed `execute` dispatches via `target` again; boot - hydration validates each row post-conversion and diagnoses invalid ones - with `[metadata_spec_invalid]` instead of shrugging. **The rows themselves - can now be brought forward too (#4327):** `os migrate meta --stored` - replays the chain over `sys_metadata` and rewrites what still carries a - pre-protocol shape, through the normal write path. Optional — the read path - is the guarantee either way — but it is what makes the conversion pass a - no-op on your data instead of a permanent shim. -- **Studio's metadata forms tell the truth (#3786).** Four of seventeen forms - had drifted from their schemas, so controls saved nothing: the Object → - Capabilities toggles bound a key the schema does not declare (all seven - dead — Track history, Searchable, API enabled, Files, Feeds, Activities, - Clone), and the Fields grid offered sixteen undeclared keys (PII, Encrypted, - Indexed, …) while missing the canonical spellings for five renames. Forms - are reconciled and gated registry-wide; re-author anything configured - through the dead controls. -- **Authored view filters compile (#3948, #4029).** Eight canonical - `VIEW_FILTER_OPERATORS` members (`equals`, `before`, `after`, …) that - validation accepted but the AST refused now lower to real queries on every - driver, and an uncompilable filter element **throws** instead of matching - every row. `saveMeta` persists canonical operator spellings, so the ~30-entry - legacy alias bridge stops growing and can eventually retire - (objectui#2945). -- **Analytics stops guessing (#4157, #4128, #4033).** An undeclared measure or - an out-of-vocabulary measure `type` **errors** instead of silently answering - `COUNT(*)` (revenue charts that were quietly row counts now say so); the - silently-dropped filter family — `$between`, `$null`, `$startsWith` / - `$endsWith`, inverted `$exists: false`, `$notContains`, and the `$or`/`$not` - combinators — now actually filters, so dashboards stop drawing every row; - time-bucketed queries project their bucket column (a trend chart got N - values and no x-axis). Time-typed dimensions gain chronological default - order, and `ReportSchema.order` becomes a declarable ordering lowered into - the dataset selection (#3916). -- **Temporal correctness lands as one campaign (ADR-0053).** `Field.datetime` - and `Field.time` get one canonical storage form per backend — mixed - epoch/ISO storage made a two-sided date window return **zero rows** on - SQLite while matches existed, and MySQL moves to `DATETIME(3)` with - UTC-pinned connections (REST datetime writes on MySQL previously failed - outright); legacy columns converge at schema sync and `os migrate plan` - lists the in-place work with row counts (#3912, #3994, #4047, #3954). A - bare `YYYY-MM-DD` upper bound covers its whole calendar day across SQL / - memory / MongoDB / the RLS write-side evaluator — dashboards lost the final - day's rows after midnight, and a `{ $lte: '{today}' }` RLS `check` policy - denied every write made after 00:00 (#3777, #4042). The type-blind - evaluators compare a JS `Date` against wire text correctly — fail-closed - RLS was denying legitimate SDK writes whose payload carried `new Date()` - (#4191). `date` `NOW()` defaults resolve UTC on every dialect (#4022). A - shared conformance matrix in `@objectstack/spec/data` is asserted by five - backends so none of these classes can silently re-drift. -- **Sharing authority is real authority (ADR-0111, #3902).** The - share-management surface authenticated the caller and then ran under - `SYSTEM_CTX` — **any signed-in user could revoke anyone's share, enumerate - who-sees-what, write self-grants, and define org-wide sharing rules**. Now: - `canManageShares` (system, record owner, Modify All Data, or hierarchy - write-scope depth) is enforced in the service on every - `/data/:object/:id/shares` verb; `listShares` is management-gated and the - open `sys_record_share` read surface is self-scoped for non-admins; the - whole `/sharing/rules` surface requires the new **`manage_sharing`** - capability (seeded into `admin_full_access`); an `edit`-level share no - longer confers **delete** (`canDelete` = ownership, write depth, or - `modifyAllRecords`; bulk deletes stop widening through shares); revoke - validates the share belongs to the URL's record; grants on objects the - sharing gates never consult fail `SHARING_NOT_ENABLED` instead of sitting - inert; and a record's owner or a Modify-All admin — not just the minter — - can revoke a share link. - -#### Security corrections - -Beyond the sharing-authority work and the #3896 enforcement half documented -above, four fail-opens were found and closed in this window — all ship in -rc.1, and administrators should know what changed: - -- **The project-membership gate never ran (#4127 sweep).** - `enforceProjectMembership` probed the auth service through a shape it never - had, so `userId` stayed unset and a **signed-in non-member passed the - gate** on every deployment with project scoping on. Found by the new typed - service-slot lint; the lint stays on to keep the class closed. -- **Analytics answered without a caller (#3891).** The degraded shim served - RLS-free, tenant-unscoped aggregates (removed — see above). -- **Dev stubs inverted their decisions (ADR-0115, #4093).** With - `plugin-security` absent, dev filled the slots with allow-all / no-RLS / - unmasked fakes behind one warn line; the slots now stay empty and fail - closed, and `plugin-dev` refuses `NODE_ENV=production` outright - (`OS_ALLOW_DEV_PLUGIN=1` overrides, and now brands the boot loudly instead - of starting silent, #3900). -- **Imported users skipped field coercion (#4251 sweep).** The - `/admin/import-users` route probed a method its service never had, so rows - reached `sys_user` uncoerced on every deployment. The same typed-slot - campaign fixed `handleAuth` reaching the (now deleted) mock instead of the - real auth service, and `/analytics/sql` crashing 500 on providers without - `generateSql`. - -#### New capabilities (backend) - -- **Approvals grow up as a decision surface.** A `decisionOutputs` entry can - declare `required: true` — an approve carrying a blank routing value is - refused before any write, instead of the next node faulting or the run - stalling (objectui#2955); a reassign writes structured `reassign_from` / - `reassign_to` parties that timelines render without free-text archaeology - (#4365); and a `runAs: 'system'` flow's writes are audited as - `svc:flow:` instead of "Unknown user" (#4366). That label is a - fallback, not a replacement for the operator — it stands in only for a run - that resolves no user at all (a schedule, or a system flow fired by a write - that itself carried no user). Where the trigger does resolve a user, #5494 - later on this page carries that user through unchanged, so the audit row - still names the human. -- **Run summaries learn honesty about side effects (#4395, #4396).** - `connector_action` and mutating `http` nodes report `unmeasuredEffect` (the - platform cannot know whether `crm.push_opportunity` writes), an - `unmeasured_count` column joins `sys_automation_run`, and the broken-sweep - detector becomes `selected > 0 AND acted = 0 AND unmeasured = 0` — without - the third clause it would fire on every healthy connector-driven flow. -- **Scheduled jobs actually fire.** Cron jobs registered before - `kernel:ready` landed on a placeholder adapter that silently ignores `cron` - schedules — in the default configuration a business plugin's cron jobs - never ran. Early registrations now migrate onto the DB-backed adapter, so - operators will observe scheduled work starting to happen. -- **`os migrate` keeps its promises (#3917, #3924, #3954, #3955, #3978).** - Zero database writes before the confirmation prompt (boot-time DDL is - deferred and rendered in the plan); a busy SQLite database is detected — - file-descriptor probe naming the holding pid, which works on the default - rollback-journal mode — and refused without `--force`; the plan lists - in-place datetime/time normalisation with row counts so large rewrites can - be scheduled into maintenance windows; zh-locale deployments stop being told - to drop their live pinyin `__search` companion columns as "destructive - orphans"; and virtual formula fields stop appearing as forever-pending work. -- **Boot tells operators the truth (#4012, #4085, #4095, #4110, #4096, #4167, - #4002).** Plugin boot-phase warnings survive the startup banner (previously - discarded wholesale — including the ADR-0110 action-governance inventory); - `os serve ` boots a fresh project with no compiled artifact instead - of dying with a misleading error, and grafts the config module's - `onEnable`/`functions` onto artifact boots so declared `script` actions stop - 404ing; a **named** artifact path that does not exist fails the boot naming - the path — it used to print "Server is ready" over an empty platform; - `OS_STORAGE_ROOT` actually takes effect (uploads land under - `.objectstack/data/uploads` from the first byte) and the spurious every-boot - "storage adapter swapped" warning fires only on a real move; `stack.storage` - — never a declared key, silently ignored — is linted with the two working - channels named (`OS_STORAGE_*`, Setup → Settings); and authored `api.*` - keys survive the serve-time config merge instead of being replaced - wholesale. -- **Platform infrastructure is composable (#4243, #4270, ADR-0116).** - `sys_migration` and `sys_secret` move to `PlatformObjectsPlugin`, so a - kernel without storage or settings keeps its migration ledger and its - encrypted-secret store — `Field.secret()` writes previously threw on - settings-less kernels. The kernel `Plugin` contract gains declared ordering - (`optionalDependencies`, `requiresServices`, `providesServices`) with - boot-time validation that names both plugins, both slots, and the fix; - `PLATFORM_ALWAYS_ON_CAPABILITIES` is a published export, so hosts stop - hand-mirroring the always-on slate (cloud's mirror had silently lost `sms`, - `messaging` and `analytics`). Graceful shutdown disconnects datasource - pools through the one datasource path, and adopted pools are never closed - by a kernel that does not own them (#3993). -- **Seeds load what you wrote (#3911, #3932).** Natural-key **arrays** resolve - for `multiple: true` lookups (previously dropped with a warn), and dropped - unusable references are counted and named in the boot banner - (`showcase 42 ok / 3 lost links`) instead of the load reading clean. -- **Authoring lints gather the evidence (#3786, #4120, #4271, #4254, #3991).** - The advisory `lintUnknownAuthoringKeys` reports every silently-stripped - object/field key across all sixteen metadata collections — with - rename/retirement guidance and nested-metadata descent — through - `defineStack`, `os validate` and `os compile`; error-severity - reference-integrity rules gate flow `update_record` / `create_record` - writes to undeclared fields and stale `searchableFields` declarations; - hook and action bodies get the same write-set analysis as advisories, plus - a runtime report for dead `ctx.record` writes (#4345); and a field carrying - both per-tenant `unique: true` and a global unique index is flagged before - the second tenant finds out. All three commands now answer identically by - construction — `os lint` and `os compile` were letting through react pages - and readonly flow writes that `os validate` rejected, including the field - resolution that **gates** (an unresolvable `` predicate - yields a silently empty list, indistinguishable from no data). Expect CI - running `os lint` to newly fail on metadata that was already broken. -- **Service slots are typed contracts (#4251, #4127).** `IObjectQLEngine` - lands (the seven consumer-local stand-in surfaces die), `getService` - resolves through a slot→contract ledger, and a shrinking baseline bans - `any`-typed lookups. This campaign is what surfaced the membership - fail-open, the import-coercion miss and the cron-adapter migration above — - the ratchet keeps the class closed. -- **Declared UI slots become authorable.** `FormSection.pane` places sections - in split forms explicitly (objectui#2153), and `element:button`'s - `properties.action` gains `InlineActionSchema` — previously authorable only - via `as any` (objectui#2997). -- **Package hygiene reaches consumers (#4261, #4248, #4097).** `CHANGELOG.md` - ships in every tarball again — 68 of 69 packages had silently severed the - delivery path for exactly the FROM→TO migration notes an upgrading agent - greps after a tombstone error; twenty packages stop shipping raw sources - and tests to npm; `os init` stamps `engines: { protocol: '^17' }` so new - packages join the ADR-0087 load-time handshake; and `spec-changes.json`, - the generated upgrade guide and the `spec_changes` MCP tool actually - report the 16 → 17 chain (they still said 16.0.0). - -#### New in Console — bundled objectui advanced `4a4829d0ef39 → 7d9734d5e321` - -143 objectui commits across five pin moves, released as **objectui 17.1.0**. -(The pin changesets enumerate 79 of them; one range under-enumerated its own -window and is recorded by `console-bebaebd39ace-backfill` — the fixes it -covers are folded into the list below rather than left to the changelog.) - -**How the Console pin is recorded on this page.** `scripts/bump-objectui.sh` -advances `.objectui-sha` whenever the bundled Console moves, and every move -writes a `.changeset/console-.md` that enumerates its own range. Each -move is reported under the *Landed since* window that shipped it — never -folded into an earlier window's range — so the chain reads end to end: -`4a4829d0ef39 → 7d9734d5e321` here (released as `rc.1`), `→ f5bc4c78be76` -under *rc.1* (`rc.2`), `→ f995a452d2ca` under *rc.2* (`rc.4`), -`→ 7dfbeb704e1e` under *rc.4* (`rc.5`), and `→ 0cf8f0f70d10` under *rc.5* -(`rc.6`). The two author-facing breaking migrations the bundle -carries (objectui#3233, objectui#3172) arrived with `f995a452d2ca` and are -documented under *Breaking changes & migration* above. - -- **The lockstep half of ADR-0110 — release-critical.** The rc.0 pin predates - the client fix, so the Console it built still posted `action.target` to - `/api/v1/actions/:object/:action`. Against a 17 server — which resolves the - declaration by `name` and refuses an unresolvable one — **every - target-bound script action would 404 from the shipped Console**. The - ADR-0066 D4 capability gate is now applied on every action surface, and a - modal action is client-side only (its server fall-through is dropped). -- **Action feedback stops lying.** A failed server action no longer shows the - green success toast, `redirectUrl` finally fires, one source now owns the - `/actions` envelope rule, and the Console reads the single-wrapped - responses the server started sending (#3962). A server rejection that names - fields marks **those** fields in the form; error-code branches survive the - ADR-0112 rename; a 400 from the server no longer reads to the user as - "check your connection"; and settings validation errors render against the - fields that caused them. -- **Filters, sorting and export agree with the server.** Every spec view - operator is bridged onto the filter AST (the client half of the operator - parity above); a view's own filter no longer vanishes when the user adds - one, nor arrives as a predicate on columns that do not exist, nor depends - on whether the query expands a lookup; a column-header sort orders the - whole list rather than the visible page, and a string `$orderby` reaches - the server as a sort instead of a list of character indices; sorting a - lookup column stops ordering by an invisible key; exporting a searched list - downloads the searched rows, not the unsearched superset; and every column - resolver reads one identity key, resolved at ingestion and reported out - loud. -- **Forms stop losing what you typed.** A tabbed or sectioned modal keeps - **every** tab's values and a split form keeps **both** panels' — previously - only the active pane survived the save; a `defaultValues` change no longer - discards the field being filled (and new defaults apply in the commit that - renders them); leaving a page with unsaved input in a modal or drawer form - is guarded; swapping `recordId` no longer leaves the previous record on - screen; and a wizard that ends on a field-less review step can finish. -- **Edits are safer.** Form edit saves send `If-Match` and surface 409 - conflicts instead of silently overwriting; a wizard with `allowSkip` no - longer submits past skipped fields; a select no longer wipes itself when - its value outruns its options; numeric and boolean option values survive - selection typed; multi-value lookup is selectable in inline edit and - hydrates through a batched `$in` (showing loading rather than the empty - placeholder); tabbed and split forms honour the form view's own `columns`. -- **Spec values render instead of failing three different ways.** Three tiers - of spec-declared values were silently wrong, validated into nothing, or - red-boxed the surface; they now render. The spec→FilterBuilder operator - table covers the whole view vocabulary, a spec `series[].type` draws (and a - spec-shape `series` plots at all), a chart says so when its rows carry no - category key instead of drawing an empty axis, a missing analytics - capability stops rendering as an empty KPI, a legacy string row action runs - instead of green-toasting a no-op, and a flow or action that failed under - HTTP 200 stops reporting success. -- **Bulk actions become per-record.** An object-declared bulk action runs over - the selection, `visible` is evaluated per selected record, params render - through the shared form-field widgets (lookup errors get Retry, `sys_user` - params get the PeoplePicker), and a bulk delete clears the row checkboxes. - The `bulkEnabled` derivation is dropped — the spec key is a tombstone. -- **Approvals and notifications.** Decision outputs reach both decision - surfaces; a reassign hand-off reads legibly and `svc:*` audit actors render - as "System"; the Console mounts the notification surfaces, each spec - `displayType` gets its own presentation, and the spec `icon`, config, - position and action variant are read instead of forked or ignored; - Attachments become a peer tab with a live count badge, translated. -- **Studio and the designers.** A page button created in Studio can be given - an action (the `InlineActionSchema` above is its contract); the script - node's form authors what the executor actually runs; a published - `configSchema` can no longer delete a node's sibling-block editors; and the - flow inspector preserves sibling blocks. `SplitForm` honours the new - `FormSection.pane`. -- **Types stop forking the spec.** `Page`/`App`/`Dashboard` validate the - spec's own fields instead of passing them through; navigation metadata - stops losing spec fields the renderer already honours; the `*Validation` - five and five more spec-named symbols are derived rather than copied; and - the form-field boundary between spec and runtime is declared, with a - tripwire against re-importing the wrong one. The SDUI public contract is - curated and guarded against silent drift, with declared inputs for the - `page:*` / `element:*` / `record:*` block families. -- **Resilience and chrome.** A transient `/me/permissions` or - `/me/localization` failure retries instead of stranding the app on its - loading state; a 403 is no longer blamed on the network; ⌘K search is no - longer capped at 8 objects; the chart view gets a label and an icon in the - view switcher; and the developer-voiced default form subtitle is dropped. - -### Landed since 17.0.0-rc.1 - -These changes are on `main` after the `rc.1` cut and **roll into 17.0.0-rc.2**. -At the time of writing the window had left 209 changesets pending — 46 -`major`-class, 74 `minor`, 55 `patch`, and 34 that release nothing (CI, tooling -and docs); the cut itself released **166 changesets — 45 `major`-class, 69 -`minor`, 52 `patch`**, counted across the `17.0.0-rc.2` sections of the package -changelogs. The Console pin stood at `7d9734d5e321` when the window opened and -advanced twice inside it — to `785b8a5d432c`, already the value when the -paragraphs below were written, and then to `f5bc4c78be76`. The two moves are -enumerated in `console-785b8a5d432c` and `console-f5bc4c78be76`; neither -carries an author-facing breaking migration, and the two that exist arrive in -the next window. - -Two campaigns dominate the window, and both are *finishing* rather than -starting: the #4535 dual-source convergence and #4001's close of the authorable -surface. Around them, the ADR-0049 enforce-or-remove sweep reaches the driver -and datasource contracts, ADR-0078 grows a completeness gate plus a runtime -twin, ADR-0119 makes multi-write atomicity real and migrations crash-safe, and a -run of security and fail-loud corrections lands. - -As in the rc.0 window, landings that belong to a section above are documented -**there** rather than repeated here: - -- **`findOne` must say which record it wants** (#4419) — the missing-predicate - read that returned an arbitrary first row. -- **The `workflow` service slot, `datasource.readReplicas`, the two connector - clusters and the `kernel` metadata-loader envelope family** — all in the dead - spec clusters table (#4451, #4468, #4480, #4499, #4411). -- **`MetadataWatchEvent.type` narrows to the three values the runtime emits** - (#4536), and **`MetadataExportOptions` / `MetadataImportOptions` move to - `./contracts`** (#4538) — both in the upgrade checklist. -- **`os migrate meta --stored`** (#4327) — in the checklist as the optional - stored-metadata pass. - -#### One name, one declaration — the #4535 dual-source campaign closes - -A bare name exported from two `@objectstack/spec` subpaths, resolving to **two -different declarations**, is the #4411 trap: an editor's auto-import picks by -name, the shapes overlap enough to compile, and the mistake surfaces far from -its cause — an `undefined` at an edge value, or (when neither side is -`.strict()`) every foreign key silently stripped. Seventeen clusters are closed -here, each judged individually against an import-statement-level scan of all -three repositories (framework, cloud, objectui): the declaration that actually -flows at runtime keeps the bare name. - -| Cluster | Name(s) | Resolution | -|---|---|---| -| C1 (#4572) | `WebhookConfig` / `WebhookEvent` | removed from `./api` — the bare names are `./integration`'s alone; `./api`'s OpenAPI webhook *descriptor* is renamed `OpenApiWebhookEvent` | -| C2 (#4587) | `MetadataEvent` / `MetadataBulkRegisterRequest` | removed from `./kernel` (a lifecycle envelope nothing emitted); `./api` owns them | -| C3 (#4610) | `Notification` / `NotificationConfig` | removed from `./ui` (a toast shape objectui never adopted) and `./system`; `./api` owns them | -| C4 (#4641) | `Session` | `./identity`'s declaration removed — the two did not even agree on field names, so a wrong pick failed as a runtime `undefined`; `./api` keeps the names | -| C5 (#4653) | `ActivationEventSchema` | converged on `./kernel`'s **structured** shape (`./studio`'s `z.string()` validated nothing); `./studio` re-exported it — then #4657 retired the whole vocabulary, see below | -| C6 (#4658) | `EventSchema` | `./automation`'s orphan state-machine *signal* declaration deleted (zero key overlap with the event-bus envelope); `./kernel` is the single source | -| C7 (#4741) | `PackageDependency` | `./kernel` renamed `ResolvedPackageDependencySchema` (the resolver's edge); `./cloud`'s manifest declaration keeps the bare name | -| C8 (#4661) | `RetryPolicy` | one declaration carrying the union of both; `./automation` and `./system` re-export it | -| C9 (#4684) | `RateLimitConfig` | `./integration` renamed `ConnectorRateLimitConfig` — the two limit **opposite directions** of traffic, and spell the window `windowSeconds` vs `windowMs` | -| C10 (#4740) | `EnvironmentArtifact` | single declaration on `./system` holding the **live wire** shape, re-exported by `./cloud`; the never-implemented v0 family (`functions` / `manifest` / `payloadRef` + 8 sub-schemas) removed | -| C11 (#4688) | `HttpRequest` | `./shared` holds the one declaration | -| C12 (#4703) | `FieldMapping` | **three** declarations → `ConnectorFieldMapping` and `ImportFieldMapping`; `./shared` keeps the base | -| C13 + C15 (#4738) | `DataSyncConfig` / `ConflictResolution` | the automation-side L1 "Simple Sync" layer (17 exports, zero consumers) deleted; `./integration`'s enum renamed `ConnectorConflictResolution`; `./ui`'s offline-sync vocabulary keeps the bare name | -| C14 (#4691) | `HttpMethod` | `./ui`'s 5-value subset renamed `HttpMethodType`; the value sets differed, not just the types | -| C16 (#4739) | `TenantPlan` + the tenant-provisioning family | `./system`'s never-implemented provisioning protocol deleted (including `IProvisioningService` / `ITenantRouter` / `ResolvedTenantContext`); `./cloud` untouched | -| C17 (#4737) | `ActionLocationSchema` | `./studio` renamed `ActionContributionLocationSchema` (3 Studio-shell values); `./ui`'s 7-value app-UI vocabulary keeps the bare name | - -Three more convergences land alongside them: the dual-source `MetadataFormat` / -`CacheStrategy` enums, which had diverged on their **values** (#4537); the -eleven `./contracts`-vs-domain parameter/result names (#4538); and the three -cross-form names, where the two sides did not share a *form* — `ShareRecipientType` -→ `RecordShareRecipientType`, plus `TransformType` and `suggestFieldType` (#4539). - -**None of this is authorable metadata**, so there is no `os migrate meta` step -and no tombstone: these are type and schema *exports*, and every break is a -compile-time `TS2305` naming the symbol. Fix it by changing an import path or a -name. The campaign leaves a gate behind — `dual-source-exports.baseline.json` is -now ratcheted by **symbol identity**, not by name (#4446), so two entries -exporting the same name from different declarations fail the build. - -#### The authorable surface closes — #4001 reaches zero - -The unknown-key strictness campaign began with a single object schema and ends -in this window. Every authorable type now rejects unknown keys with a -prescription that names the offending key and its canonical spelling: - -- **`ObjectSchema` closes on the PARSE path**, not only in `create()` (#4522) — - the founding example of #4001, which had been live only at one of its two doors. -- **`field`** (#4531), reusing the curated table that already recorded which - advice would have been wrong. -- **`dashboard`** header, filter bar and root (#4532). -- **`action`** (#4533), taking the ADR-0010 protection-envelope debt list to zero. -- **`mapping` / `agent` / `page`** (#4530) — and `strictObject` stops suggesting - keys that were removed. -- **`translation`** bundles and items, at both doors (#4529), retiring #3778's - bespoke ten-key guard into the shared error message. -- **The six `validation` variants**, each against its own key set (#4527). -- **Six more registered types** — `report`, `dataset`, `email_template`, - `skill`, `job`, `book` (#4528); `skill`'s silently-stripped `permissions` - stops pretending to be a gate. -- **The Studio authoring surface** — plugin manifests, the flow builder, the - object designer (#4541). -- **The view surface** — the container, both view kinds and the ~28 config - shapes under them (#4534). This is the last batch. - -`strictObject` (#4514) is what made the tail cheap: closing a shape is one call, -with `seed` and `doc` converted first. One gate turned out to be hollow on the -way through — the protection-envelope invariant test silently skipped 24 of 25 -registered types; fixed, it immediately found 8 undeclared envelopes (#4519). -The deleted-baseline discipline tightened too: a removed `authorable-surface.json` -line must now prove itself rather than being taken on trust (#4650). - -#### ADR-0049 enforce-or-remove — the sweep reaches the driver and datasource contracts - -The v16 sweep took the *authoring* surface; this one takes the **contracts**. -Each removal below was declared, strict-guarded or `required`, and read by -nothing. - -| Removed | Why | -|---|---| -| 31 of 34 `DriverCapabilities` bits (#4634) | written by every driver, consulted by no engine, planner, REST layer or renderer. With zero readers the values went wrong unnoticed: `SqlDriver` declared `streaming: false` while implementing `findStream`; `InMemoryDriver` declared `streaming: true` over a full-table read — the exact inverse of the guarantee. Three bits have a real reader and stay | -| `IDataDriver.findStream` (#4484) | a **required** contract method with no caller anywhere, documented as the memory-safe large read — and its two main implementations did the opposite | -| `IDataEngine.batch?` (#4618, ADR-0119 D3) | declared for the life of the contract, implemented by nothing, called by no one. Use `engine.transaction(cb)`, `batchData` with `options.atomic`, or `POST {basePath}/batch` | -| `datasource.retryPolicy` (#4583 B) | four keys read by no connect or query path — nothing ever retried on them. Do **not** "fix" this by renaming: `hook.retryPolicy` / `job.retryPolicy` are enforced, spell the delay `backoffMs`, and are a different key on a different type | -| `datasource.healthCheck` (#4583 C) | no probe loop ever existed. Liveness is probed on demand via the driver handle's `ping()` / `checkHealth()` | -| `datasource.capabilities` (#4583) | eleven booleans, none read — pushdown is decided by the runtime driver's own `supports.*`. `readOnly` is why this is not tidy-up: it reads as a safety property, the shipped CRM example labelled a datasource a "Read Replica" on the strength of it, and the datasource accepted writes exactly like the primary | -| `external.label` / `external.requirePermission` (#4583 D) | a second display name that never displayed, and a permission no authorization check ever consulted | -| `RestServerConfig.openApi31` (#4579) | `normalizeConfig` forwarded five keys and silently discarded this one; the served `/openapi.json` never consulted the config | -| `activationEvents` (both keys) + `ActivationEventSchema` (#4657) | lazy plugin activation no runtime in four repositories ever implemented — every plugin has always activated on load | -| The standalone `validation` metadata kind (#4509, ADR-0088) | `ValidationRuleSchema` carries **no object-binding key**, and all six variants are `strictObject`, so an author could not supply one. A rule authored through that door parsed, saved, reported success and intercepted nothing — including a `state_machine` rule, so an author could believe they had locked down state transitions and have changed nothing | -| `job` runtime creation and org overrides (#4509) | `JobSchema.handler` names a function in the compiled bundle's function table, which a runtime writer cannot reach — so a job created in Studio or via `PUT /meta` saved and never ran. `allowRuntimeCreate` / `allowOrgOverride` are now `false`; `job` stays first-class through `*.job.ts` and `defineStack({ jobs })` | -| Six `authorWarn` dead keys (#4667) | `book`/`group` `translations`, `job.id`, `translation.validationMessages`, `app.homePageId`, `app.areas[].order` — each shaped so an author reasonably concludes it configures something | -| Five unwarnable keys (#4509) | mapping `extractQuery` / `errorPolicy` / `batchSize`, contextSelector `includeAll` / `placement`. Four carry schema **defaults**, and a default materialises at parse time — so the advisory lint could not tell an authored value from a supplied one, and removal was the only channel that reaches the author | -| `app.areas[].visible` / `.requiredPermissions` (#4651) | not inert — **fail-open**. See Security corrections | -| `sys_comment.visibility` / `.reply_count` (#4756) | `visibility` is a security-looking key with no gate behind it: a comment marked `private` was visible to exactly the same people as a `public` one. After #4630 there is no replacement key — access is the record's | -| `DataEventType 'data.field.changed'` (#4673) | no producer, and unimplementable against `DataEventSchema` as written — the payload is record-shaped, with no `field` / `oldValue` / `newValue` slot | -| The orphan notification-template vocabulary (#4616) | `EmailTemplate` / `SMSTemplate` / `PushNotification` / `InAppNotification` existed only as members of the union #4610 deleted. Use `EmailTemplateDefinition` and its siblings | -| `script` node `actionType` branches (#4343) | see below | - -Two entries in the same batch went the **other** way, because a bridge existed: -`doc.tags` is now declared, so a book group's `include: { tag }` can finally -match something, and declared email templates reach the mail service (#4509). -Closing a door and building a bridge are the same discipline applied to -different evidence. - -One rename rounds it out: **`managedBy: 'system'` becomes `'system-data'`** -(#3355). ADR-0103 split the overloaded bucket additively in v16, which left the -surviving value naming the half that had already moved out — `system` sitting on -precisely the objects a user writes. `system-data` states both boundaries: the -schema is the platform's, the data is the admin's or the user's. One deliberate -consequence — the affordance default flips from LOCKED to **WRITABLE**, so the -`userActions: { create, edit, delete }` blocks the eight platform objects used to -re-open their writes with are now redundant and deleted. Keep `userActions` only -to *narrow*. - -#### Author-time gates: a completeness test, and a fourth door - -**ADR-0078 completeness (#4544).** An instance can be Zod-valid, use only live -properties, and have a correctly-authored sibling that provably runs — and still -be dead, because it omits a config its consumer needs and the consumer silently -no-ops. The founding case: an AI authored `{ type: 'summary' }` with no -`summaryOperations`, the index builder skipped it, the field read `0` forever, -and every gate the author could see was green. This is worse than the unknown-key -hole #4001 closed: there the author wrote a key we don't know and the parse -rejects it; here every key is one we know and the author gets a **success**. - -The judgement now lives in `@objectstack/spec/kernel` (`checkFieldCompleteness` / -`checkViewCompleteness`) and is consumed by `@objectstack/lint`'s -`validate-functional-completeness`, so `os build` / `os validate` / `os lint`, -MCP and hand authoring are all covered rather than only cloud's AI-build -graph-lint. Every rule cites the runtime line that makes it true: - -| Rule | The silent skip | Severity | -|---|---|---| -| `field/summary-without-operations` | `engine.ts` — `if (!d.summaryOperations) continue` | error | -| `field/formula-without-expression` | the formula plan is built only from fields that have one | error | -| `field/relationship-without-reference` | `$expand` — `if (!referenceObject) continue` | error | -| `field/choice-without-options` (`select`, `radio`) | `record-validator.ts` — an empty option list disables server-side value validation | error | -| `field/choice-without-options` (`checkboxes`) | same branch, shared with free-form | warning | -| `view/layout-without-binding` (`kanban`, `calendar`, `gantt`) | the renderer falls back to literal default field names | warning | - -The deliberate **non**-rules are pinned as hard as the rules: `multiselect` -without options is blessed by `record-validator.ts` as a mode, so flagging it -would be another false prescription. Phase 3 adds the shapes whose verification -passes are done — a webhook with no `triggers` (#4565), an action nobody placed -(#4501), and nav targets that are not object names (`page` / `report` / -`dashboard`, ADR-0072, #4574). Phase 4 gives the field rules and the webhook -rule a **runtime twin** (#4599): `SchemaRegistry.registerObject` — the choke -point every metadata door converges on — emits the *same rule ids* at -registration, one aggregated line per object, and **warns without ever -throwing**. An inert field must not kill a boot that thousands of healthy -objects share. - -**The fourth door (#4463).** The 26 author-time rules `os validate` / `os build` / -`os lint` share ran on those three commands and nowhere else — but every runtime -metadata write (Studio's designer, REST `/meta` CRUD, an MCP agent authoring a -flow) reaches `saveMetaItem`, which did a Zod `safeParse` and stopped. For a -tenant, that was not the weakest of four doors, it was the **only** door: a -`sys_metadata` overlay row is not in the CLI's config file, so there was no -command they could run instead. An approval flow whose `expression` approver is -broken CEL is Zod-valid, so it saved, registered, and failed at the node's entry -the first time it fired. - -- `AUTHORING_RULES` moved from `packages/cli` into `@objectstack/lint`, with a - kernel-safe `@objectstack/lint/runtime` subpath that loads neither `typescript` - nor `sucrase`. There is one table, and a second cannot be introduced without - failing `authoring-rule-wiring.test.ts`. -- A `state: 'active'` `saveMetaItem` — and the draft→active promotion in - `publishMetaItem` — of a **flow** runs the flow / approval / expression / - reference rule families and refuses a gating finding with **422 - `INVALID_METADATA`**, carrying `rule` / `path` / `where` / `message` / `hint`. -- **Draft saves are never gated.** Only the write is judged: rules run twice - (with and without the submitted item) so a pre-existing violation in a stored - row never blocks an unrelated save. -- Escape hatch `OS_ALLOW_UNLINTED_METADATA_WRITES=1` turns the refusal into a - loud log for a migration window. Only `flow` writes are gated in this pass; - every other type carries a recorded reason in the registry. - -Alongside them: every author-time rule that *can* gate now runs on all three -commands (#4409); view `searchableFields` is validated at build time against the -same judgement the runtime applies, so a `lookup` typo no longer waits for a 400 -(#4830); `has(x)` is not a null guard, and publish rejects unguarded nullable -comparisons (#4763); `bulkActionDefs` gets a real shape and its aggregate name is -linted (#4457); `validateFormLayout` is wired into the registry; and the liveness -gate now governs **every** registered metadata type (#4487, #4488). - -New CI gates land with them — `check:adr-anchors` so governed code keeps naming -its decision, expanded to the full authz/security ADR set (#4575); -`check:init-service-contract` (#4471, ADR-0116); a startup-registry-verdict gate -(#4777); a driver-conformance run that discovers zero drivers now **fails** -(#4646); `check:i18n` fails on an undeclared authoring key rather than only on -bundle drift (#4804); and `check:react-conformance` is renamed -`check:react-declaration-parity`, because it compares two declarations and said -it compared a declaration to an implementation (#4472). - -#### Atomicity and durable migrations — ADR-0118, ADR-0119 - -- **`atomic` becomes a guarantee (#4612, D1/D4).** `batchData`'s - `options.atomic` promised "rollback entire batch on any failure" and delivered - a `break`: every write before the failure stayed committed, and the response - reported those rows `success: true` under the one flag whose job is to - guarantee they were undone. An explicitly atomic batch now runs inside one - `engine.transaction()`; rows come back `ROLLED_BACK:` / `NOT_ATTEMPTED:` with - the causal error, and **no row reports success**. On a runtime that cannot roll - back the request is refused with **501 `NOT_IMPLEMENTED`** rather than - degrading silently. `IObjectQLEngine.transaction` joins the slot contract, so - plugin space reaches it without `as unknown as` casts. - **The declared default flips `true` → `false`** — aligned down to what every - site already did, rather than up to what none of them did. -- **Its two siblings follow (#4620).** `deleteManyData` was fake-atomic (a - partial delete with no natural undo, reported as atomic) and `updateManyData` - ignored `atomic` entirely. Both now run the same shared atomic runner, so a - fourth copy of transaction handling cannot drift into a fourth lie. -- **The migration journal (#4617, D2).** Migration-class work does not fit in - one transaction: a million-row backfill cannot hold a write lock, and a - process *killed* defeats in-process rollback entirely. `runMigrationJournal` - (`@objectstack/core`) preflights every step's read-only validator before any - step writes, chunks the rows, runs each chunk inside `engine.transaction()`, - compensates newest-first on failure, and on restart resumes forward from the - first chunk lacking `chunk_done` — or unwinds, per the plan's `onCrash` - policy. The invariant that carries it: `chunk_done(i)` is written **inside** - the chunk's own transaction and `chunk_started(i)` autonomously before it, so - `started ∧ ¬done` has exactly one meaning. `sys_migration_journal` is - registered unconditionally, because recovery must be discoverable with zero - host wiring. Boot reconciliation and `os migrate resume` make an interrupted - run impossible to miss. -- **Actors are typed, not stringly (#4608 ADR-0118, #4586).** A non-user actor - is represented as `NULL` with an explicit `isSystem`, fail-closed, and - better-auth's real operator is now carried onto identity-table writes so - `sys_member` history stops saying "system". `sys_metadata_history.recorded_by` - is declared `Field.lookup('sys_user')` and was being filled with the **string** - `'system'` — an id that dereferences to nothing under any reading; it stores - `NULL` now (#4556). -- **Stored metadata gets a finish line.** `os migrate meta --stored` rewrites - `sys_metadata` rows in place (#4327) and now covers flow rows too (#4454); - `POST /meta/_migrate-stored` runs it without a shell; `saveMetaItem` - canonicalizes flow bodies on write, so a Studio edit heals a legacy row like - every other type's (#4542), and says so out loud when it had to skip - canonicalization (#4580); `duplicatePackage` stops minting pre-protocol flow - rows (#4498). - -#### Protocol & wire changes - -- **Batch per-row results deliver their declared shape (#4793).** The rows of - `POST /data/:object/batch`, `/updateMany` and `/deleteMany` had drifted from - `BatchOperationResultSchema`: the schema, the SDK's exported - `BatchOperationResult` and the reference docs all said `errors: ApiError[]` / - `data` / `index`, while the wire carried `error: string` / `record` and never - sent `index`. A TypeScript consumer written against the published type - compiled, validated, and read `undefined` at runtime. A conformance pin now - parses every emitted row against the schema. `ROLLED_BACK` and `NOT_ATTEMPTED` - are registered batch-row error codes. -- **`IAutomationService.getSuspendedScreen(runId)` is async (#4515).** A - synchronous signature could only read the engine's in-memory hot cache, so - after a restart a still-suspended screen run could be *resumed* (200) while - `GET …/runs/:runId/screen` answered 404 — the refresh-safe re-fetch failing in - exactly the situation it exists for. It now falls through to the durable store - that `resume()` rehydrates from. One-line fix: `await` the call. -- **`sys_comment` derives its access from the record its thread names (#4630).** - See Security corrections. -- **The app-metadata gate filters inside `areas[]` (#4722).** Response shape - tightens: gated navigation items no longer appear at all, and an area filtered - empty is stripped. -- **Discovery stops advertising routes for kernel-internal slots (#4318)** — - cache, queue and job have no HTTP surface. -- **Metadata audit history and global search sort by `order`, not `direction` - (#4674)**, and the `/meta` read/write/delete boundary settles on one canonical - type key (#4432). -- **Four ADR-0112 envelope defects found in the v17 verification sweep** are - closed (#4431, #4435, #4436, #4483), along with the `$search` field set. -- **A stored reference value that is an embedded record is no longer a valid id - (#4455).** `ReferenceIdValueSchema` was `z.string().min(1)`, and in SQL a - legacy embedded reference reaches storage as JSON *text* — a non-empty string. - So `os migrate value-shapes`, the evidence half of the ADR-0104 D1 gate, ran - the scan on a deployment carrying exactly the values it exists to find, was - told it was clean, and closed the gate. - -#### Metadata authoring & runtime changes - -- **A hook `condition` the platform cannot evaluate now ABORTS the operation - (#4775).** It used to `logger.warn` and `return false` — the hook simply did - not fire. "The condition said no" and "the platform could not work out what the - condition says" carry **opposite** risks depending on the hook, and collapsing - them into one outcome meant a `before*` guard silently let writes through. - Hooks that have been getting by on that skip will now fail the write; that is - the point, not a side effect. -- **A hook `condition` reads the record, and can express a transition (#4770, - #4784).** The gate evaluated against `ctx.input.data` — only the fields the - write happened to carry — so `condition: "record.done == true"` did not run on - the most ordinary updates there are. It now evaluates against **stored ⊕ - payload**, total over the object's declared fields, and the CEL scope binds - `previous` alongside `record`, which both published skill docs already taught. - Write a transition as `previous.done != true && record.done == true`. -- **Validation rules fail CLOSED (#4649).** A `script` / `cross_field` / - `conditional` rule whose predicate faulted was logged at WARN and **skipped**, - so the write went through while the rule stayed declared and enforced nothing. - The merged record is now total on update as well as insert, and a predicate - that still cannot be evaluated rejects the write with `VALIDATION_FAILED`, - naming the rule and the key it read. `severity` still governs blocking. -- **A `script` node is a function call (#4343).** It had four ways to name what - it ran and only one ran anything: `actionType: 'email' | 'slack'` were - logger-backed stubs that reported success and delivered nothing, `template` / - `recipients` / `variables` fed those stubs, and an inline `config.script` was - recognized and never executed (there is no server-side JS sandbox). What - remains is what worked — `config.function` (now **required**), `config.inputs`, - `config.outputVariable`. `script` and `subflow` config is parsed at execute - time. -- **A `user` field carries its target in the TYPE (#4438).** `Field.user()` - writes `reference: 'sys_user'` itself, but two callers read `field.reference` - raw and disagreed, so `?expand=` answered - `400 INVALID_FIELD … declares no target object`. Metadata authored without the - redundant key — hand-written JSON, an AI author, a Studio form — was read as - under-specified when it was complete. `referenceTargetOf` is now the single - arbiter both halves of the expand path read. -- **A `datasourceMapping` rule is routing, not a hint (#4462).** Map an object to - a Postgres datasource with a bad URL and the boot succeeded, `/ready` answered - 200, the datasource name appeared in zero log lines, the write returned 201 — - and the row was physically in the **default** store. A mapping that matches and - names a datasource with no live driver now throws. -- **`datasource.config` is parsed against its driver's contract (#4410)**, and - the driver factory's four legacy `??` fallbacks graduate into an ADR-0087 - conversion instead of staying as consumer-side aliases (#4456). -- **Decision routing has one working model (#4414, #4440).** The two shapes that - could never route are gated at author time, the inert `config.condition` is - flagged, `isDefault` is enforced, and an unclaimable branch label stops being - swallowed. `evaluateCondition` decides its dialect from the source rather than - from the caller (#4336). -- **The `record:*` blocks are withdrawn from the react tier (#4413)** — no - renderer read the props they published. -- **Flow node-type validation waits for the plugin vocabulary to close (#4771)**, - so an approval flow is no longer mis-reported as "will fail at runtime". -- **`Seed.env` is enforced (#4836)** — environment-scoped datasets no longer seed - everywhere — and a seed replay keeps its per-org tenant stamp as an id rather - than resolving it as a natural key (#4644). -- **A `defaultValue` runtime token never becomes a column DEFAULT (#4560).** - -#### Security corrections in this window - -- **Comments were readable by anyone who could guess a thread id (#4630).** - Attachments derive visibility from the parent record; comments derived - nothing. On the same record, the same user who got `200, 0 rows` for the - opportunity and its attachments got `200, 1 row` for its comments — and could - `POST` a new one. `sys_comment` now derives its access from the record its - `thread_id` names. -- **Navigation gates inside an area were rendering-layer politeness (#4722).** - `filterAppForUser` is the server-authoritative visibility gate for app - metadata on `/meta`, but it only walked the app's **top-level** `navigation` - tree — `item.areas` was never read. `requiredPermissions` / `requiresService` - written on an item inside an area were enforced only by the client renderer, - so the item and its `objectName` / `pageName` / `componentRef` targets arrived - in the `/meta` body regardless. The same `filterNav` now runs on every - `areas[].navigation`, and the service-probe surface was widened to match — a - probe narrower than the filter would strip a live entry. Note the deliberate - asymmetry: `visible` (CEL) is still client-only at every level. **If it must - never reach the browser, write `requiredPermissions`, not `visible`.** -- **Two area-level gates failed open (#4651).** `app.areas[].visible` and - `.requiredPermissions` were not inert keys — an author wrote - `requiredPermissions: ['sales.admin']` on a navigation area, got a clean parse - and a stored value, and the area with everything under it was served and - rendered to **every user**. Both are removed, with a prescription; this is a - real migration, not a rename. -- **An unscoped multi-delete of `sys_attachment` is refused (#4757)** rather than - authorized. -- **`/analytics/query` is scoped to the caller's readable records, and a measure - over a missing field is refused (#4467, #4437).** -- **Deactivating or deleting a sharing rule now withdraws its materialised - grants (#4433, #4434)** — and `DELETE` stops answering 500. -- **The org-admin auto-grant can actually revoke (#4640)** — `tryDelete` was - calling the engine's `delete` with the wrong signature, so demoted admins kept - tenant admin. -- **Permission-set projection writes only spec-declared keys, and a failed - backfill becomes loud (#4669).** -- **AI routes carry the capability channel onto `req.user` (#4705)**, and a - degraded tenancy posture no longer hands out a default organization (#4423). -- **OTP hardening.** The per-number send budget counts in the shared store - rather than per node (#4790); the cooldown's retention follows the declared - value instead of a hard-coded hour (#4808); and the rate-limit counters - resolve the kernel cache lazily, which fixed both a spurious warning and the - functional hole that shared rate limiting never engaged (#4772). -- **The issuer is the provider's to declare (#4552)** — Google account links - stopped resolving under a synthesized one. -- **Approvals.** An admin override of a staffed approver slate is recorded *as* - an override (#4466); the record lock holds for predicate (`multi`) updates - (#4778); stranded requests nothing was looking at are findable (#4469); a - decision can no longer report success while its flow stays parked (#4420); and - `resume` enforces the suspended screen's declared field contract (#4477). -- **Three auth-gate disconnects close (#4509)** — `email_template` bridges to the - mail service, `job` and `validation` close their doors. -- **The ADR-0104 fresh-datastore attestation concludes on this boot's own data - (#4769)**, not on the emptiness it remembers from before the boot wrote. - -#### New backend capabilities in this window - -- **Predicate writes get an honest bulk event contract (#4639).** A `multi: true` - update or delete reaches `updateMany` / `deleteMany`, which resolve a row - **count** — satisfying neither `DataEvent.recordId` nor `before` / `after`, so - the engine used to fabricate an event with `recordId: ''`. New `BulkDataEvent` - (`data.records.updated` / `data.records.deleted` — note the plural) carries - `matched` and no `recordId`, so a consumer knows from the type alone what is - coming. Webhooks gain opt-in `bulk_update` / `bulk_delete` triggers — - deliberately **not** extra sources for the per-record ones — and the SDK gains - `client.events.subscribeBulkData`. A predicate matching zero rows publishes - nothing. -- **`subscribeData` and `subscribeMetadata` deliver what they declare** — real - `DataEvent`s (#4626) and real `MetadataEvent`s (#4602), with loud boundary - validation. -- **`@objectstack/client-react` grows bulk-write hooks, and `useAutoRefresh` - refreshes on predicate writes (#4678).** Five hooks stopped looping on - dependency identity (#4693, #4694), and the package finally has a test harness - pinning realtime behavior (#4682). -- **Blueprints can say what they compute.** A `formula` field states its - `expression` (#4577), and a blueprint can declare roll-ups, including - conditional ones (#4425). -- **Stored `lookup` references that resolve to nothing are reported (#4551)** — - an inspection that reports and never blocks. (It no longer misfires during - shutdown, where every `os migrate` subcommand was reporting `sys_metadata` and - `sys_view_definition` as unreadable — #4747.) -- **Aggregate bulk dispatch works end to end (#4461):** `_selectedIds` passes the - action param gate, so an aggregate bulk action makes one call instead of N. -- **A roll-up registered at runtime computes without a restart (#4427)**, and a - just-saved overlay is dispatchable immediately rather than after the next - listing (#4521). -- **Analytics correctness continues.** A percentage measure carries its SCALE, so - a ratio of 1 reads as 100% (#4442); a measure a query never reported reads `0` - for a count/sum on every merge seam, `compareTo` included (#4708); and - `compareTo` applies each measure's own filters, so `__compare` is the - same measure as the column beside it rather than a different one (#4820). -- **Boot and operations tell the truth.** A `sys_metadata` DDL failure is loud — - only "table already exists" may be silent (#4728); the history `event_seq` is - never invented from a read that failed (#4825); a best-effort degradation that - costs **durability** logs `error`, not `warn`, with a gate enforcing the rule - (#4632); declarative `defineJob` cron jobs are actually scheduled (#4567); and - the SQL outboxes stop writing `updated_at` on UPDATE, which was flooding the - `pnpm dev` console (#4765). -- **`os serve` distinguishes "the multi-org package is absent" from "the plugin - refused to mount" (#4818)**, and resolves the enterprise organizations package - from the **host app** rather than the framework (#4699, #4700). -- **A `RETURNING` write persists on `driver-sqlite-wasm` (#4518)**, unblocking - cold-boot e2e. - -### Landed since 17.0.0-rc.2 - -These changes are on `main` after the `rc.2` cut and **roll into 17.0.0-rc.4**. -Counted across the `17.0.0-rc.4` sections of the package changelogs, the cut -released **360 changesets — 44 `major`-class, 94 `minor`, 222 `patch`**, which -makes it the largest of the train's RC cuts (`rc.0` released 258, `rc.1` 262, -`rc.2` 166, `rc.5` 10). - -**Why there is no `rc.3` window section.** `17.0.0-rc.3` was tagged for every -package on 2026-08-03 by a `chore: version packages (rc)` run, but the round -consumed exactly one changeset — `BulkActionParamSchema`'s `options[]` entry -gaining passthrough (#4001) — and left every other package with nothing but an -*Updated dependencies* bump. Those `## 17.0.0-rc.3` changelog sections no -longer survive on `main`, and the Console pin did not move in that round. So -this one window section spans the `rc.2` cut through the `rc.4` cut rather than -splitting at a tag that records nothing. - -As in the windows above, landings that belong to a section higher up the page -are documented **there** rather than repeated here. - -- **Declarative `apis:` goes from silent no-op to live, in two steps.** The - surface was zero-execution end to end and said nothing about it: metadata - loading accepted `apis:` and `GET /api/v1/meta/api` returned every endpoint - with every key intact, while no route was ever mounted — so a declared path - died at Hono's `notFound` rather than at the dispatcher. It first learned to - **refuse loudly**, retiring the `ApiRegistry` family (#4936, #4939); the - blanket refusal then narrowed to per-endpoint publish gates and declared - endpoints **went live** (#5111, #5040 E7). -- **#4001 closes the authorable surface, batches 9–20.** Unknown keys are now - rejected on the flow-node config contracts (batch 9), `control-flow` and - `state-machine` (batch 10), nine automation shapes (batch 11), the ETL - authoring contracts (batch 12), the responsive/SDUI styling shapes (batch - 13), action-param options, public sharing, report sorting, the dataset - semantic layer and dashboard widgets (batch 14), all 14 `ui/theme` sites and - 5 of 7 `ui/chart` sites (batch 15), and `AriaProps` (batch 16). View - sub-blocks (batch 18) and object inner blocks (batch 20) stop dropping - unknown keys instead of reporting them. `ui/component.zod.ts`'s 29 sites were - judged **no gate** and deliberately left open (batch 17) — a recorded - verdict, not an omission. `ViewItemSchema` splits into an authoring schema - and a wire variant. -- **ADR-0049 enforce-or-remove reaches its widest sweep.** Retired in this - window: the dashboard widget action trio plus `aria`, and the build gate that - enforced a button nobody renders (#5010); `indexes[].type` and - `indexes[].partial`, two authorable index keys no driver ever read (#5248, - #4943); `connector.rateLimitConfig` and the whole outbound rate-limit shape, - whose engine never existed (#4911); `dashboard.widgets[].responsive` (#4876); - `HookContext.session`'s deprecated position key, declared and read by two - dead branches but never produced (#5050); `HttpServerConfigSchema` (#4938); - `NotificationActionSchema` and `EmbedConfigSchema` (#5015); nine theme token - groups emitted and read by nobody (#5021); the five `ui/` interaction config - modules (#4988); `IStorageService.list(prefix)` (#5540); the field-mapping - `transform` key and `FieldMappingTransform` (#5552); and the exported - `HttpServer` delegating wrapper. -- **The caller's positions converge on one spelling.** Action-body - `ctx.session` starts emitting the canonical `positions` alongside the - deprecated predecessor key it replaces (#5613); that predecessor is then - removed outright, leaving `ctx.user.positions` as the only spelling on - action bodies and AI route handlers (#6011). -- **A misconfigured provider fails the boot instead of degrading silently.** - `OS_EMAIL_PROVIDER=resend/postmark` without an API key no longer becomes the - log transport (#5132), and a misspelled `OS_SMS_PROVIDER` no longer does - either (#5713). -- **Wire shapes stop having two answers.** `GET /meta/:type/:name` answers - exactly one body shape; a sort node spelling its direction `direction` is a - 400 rather than a silently reversed page (#4721); `DeleteDataResult` declares - the schema it names — `success`, not `deleted` (#5638); and - `@objectstack/spec/openapi.json` stops describing routes it does not own, - shrinking to the contract half it does (#5744, #5588). -- **The driver contract stops writing the object name twice.** `IDataDriver`'s - query parameter becomes `DriverQuery` (#5181) — the first step of the - narrowing that finishes in the open window below. -- **Tenancy and durability corrections.** `organization/create` is judged - against the **effective** tenancy posture, so a deployment with no - organization wall can no longer create organizations (#5261); - `HierarchyScopeContext.organizationId` becomes the authoritative tenancy - field and is required (#5858); and a `sys_file` / `sys_upload_session` write - that never landed stops reporting success (#5216). -- **Two dialect convergences.** The retry policy's last two spellings meet - (#4964 `flow.errorHandling`, #4962), and `dashboard.widgets[].compareTo` - converges on the analytics executor's contract (#5011). - -#### New in Console — bundled objectui advanced `f5bc4c78be76 → f995a452d2ca` - -One pin move, released as **@objectstack/console 17.0.0-rc.4**: 98 non-merge -objectui commits, of which 64 of 65 changesets release; the enumeration is -`console-f995a452d2ca`. This is the move that carries the bundle's two -author-facing breaking migrations — **field widgets receive their metadata on -one key, `field`** (objectui#3233) and **flow node geometry is the spec's -`FlowNode.position`** (objectui#3172) — both documented under *Breaking changes -& migration* above. - -### Landed since 17.0.0-rc.4 - -These changes are on `main` after the `rc.4` cut and **roll into 17.0.0-rc.5**. -A short window: **10 changesets — 2 `major`-class, 4 `minor`, 4 `patch`**, -counted across the `17.0.0-rc.5` sections of the package changelogs. Nine are -listed below; the tenth is the Console pin move. - -- **`system-data` no longer grants CSV import by default** (#4671). - `managedBy: 'system-data'` narrows from `create/import/edit/delete/exportCsv` - to the same set with `import: false`, leaving `platform` as the only bucket - that grants `import` by default. Eight in-repo objects lose the import - wizard, three of them the RBAC join tables that decide who can do what. - Restore any one of them with `userActions: { import: true }` on the object — - only that verb, the rest still follow the bucket default. The authorization - boundary is unchanged: `import` is an affordance that decides whether a UI - entry renders, and every imported row still passes `DelegatedAdminGate`, RLS - and permission-set adjudication. What changes is leverage — one bad CSV is a - bulk grant. Recorded as the #4671 addendum to ADR-0103. -- **`subscribeMetadata`'s `type` narrows to `MetadataEventSubject`** (#4627) — - subscribing to an event the contract can never deliver is now a compile - error. -- **`engine.transaction` tightens, batch 1** (#5696) — `opts.require` is - fail-closed, and the handle carries an `owned` signal. -- **Transaction handles stop leaking across datasources** (#5351, #5696) — a - business write refuses loudly, and the system ledger moves out of the - transaction so it lands on its own. -- **A connector action can declare what it did upstream** (#4395), which is - what makes `connector_action` countable. -- **`XParsed` naming reaches every schema that has a parsed state** (ADR-0122, - #5551). -- **`os migrate summary-nulls`** (#6063) backfills roll-up count and sum - columns left `NULL` by pre-seed inserts. -- **`runAs: 'system'` `create_record` stamps all three ADR-0118 columns** - (#5494) — organization, owner and creator are non-`NULL`. -- **The strictness ledger gains a ninth verdict, `covered`** (#5249) — an - honest cell for a shape fragment with no gate and no parse whose every - consumer already guards. - -#### New in Console — bundled objectui advanced `f995a452d2ca → 7dfbeb704e1e` - -One pin move, released as **@objectstack/console 17.0.0-rc.5**: 28 non-merge -objectui commits, 9 of 9 changesets releasing, all `patch`; the enumeration is -`console-7dfbeb704e1e`. No author-facing breaking migration. - -### Landed since 17.0.0-rc.5 - -These changes are on `main` after the `rc.5` cut and **roll into -17.0.0-rc.6**, cut on 2026-08-10. The cut released **425 changesets — 36 -`major`-class, 123 `minor`, 266 `patch`**, and none that release nothing — -making it the largest round of the train, ahead of `rc.4`'s 360 (`rc.0` -released 258, `rc.1` 262, `rc.2` 166, `rc.5` 10). The window was still open -when the entries below were first written against 135 pending changesets; the -three bullets after them cover what landed between that draft and the cut. - -- **ADR-0122 phase 2 — the bare type name becomes the AUTHOR state.** 1384 - aliases flip and 102 `XInput` synonyms retire, completing the convention - whose `XParsed` half landed in the previous window (#5551). -- **The driver query contract finishes narrowing.** `DriverQuery` reaches - `aggregate` and `distinct` on memory and mongodb (#6212 batch C) and five - further drivers (#6075); `SqlDriver.distinct`'s third parameter becomes a - bare `FilterCondition`, so a spelling that silently returned the whole set no - longer compiles (#6320); `analyzeQuery` and `findWithWindowFunctions` stop - taking `any`, and the window door carries its own flat-shape types (#6212 - batches A and E); and the two undeclared `aggregate` / `func` aliases retire - (#6212 batch B, #6321). -- **ADR-0049 enforce-or-remove continues.** The L2 ETL layer retires — - `automation/etl.zod.ts` had no executor while the sync architecture document - was still recommending it. So do `system/http-server.zod.ts`'s runtime - vocabulary; the widget-registration vocabulary and five doorless i18n shapes, - with `FieldWidgetProps` deliberately **kept**; `ViewProtocol`'s five - viewId-addressed methods and their ten schemas (#6239); and `array_agg` / - `string_agg` from `AggregationFunction`, with `count_distinct` deliberately - kept (#6188). The SDUI component props are reconciled against the renderers - that serve them — 4 keys retired, 9 declared (#5775). -- **`composeStacks` stops last-wins on `i18n`** (#5051) — the same value - passes, a genuine conflict errors with a prescription. -- **`HierarchyScopeContext` carries the tenancy posture** (#6139), so a - single-posture `DEPTH` scope is legal rather than rejected. -- **`HookContext.api` narrows from `z.unknown()` to `IScopedContext`** (#5945) - — the first hook the documentation teaches finally compiles. -- **A `view` body must be a view before the union judges it** (#5599), and an - action param's `options[]` can speak a per-option `visibleWhen` (#5016). -- **One driver vocabulary, and `mongo` becomes `mongodb`** (#6345). `spec` has - owned the driver alias table since #4410 so that the id selecting a *driver* - and the id selecting that driver's *config contract* cannot disagree — but - the table never reached the two boot hosts, so `OS_DATABASE_DRIVER=pg` was - accepted by `os start` and **refused by name** by `os migrate`, and `libsql` - the same. Both now read the one table, `DRIVER_CATALOG` publishes `mongodb`, - `turso` gains a config contract, and the datasource factory can no longer - fall through to `memory` when it fails to recognise a name. -- **Doors that were open only by omission close.** A paused node whose - descriptor never declared `resumeAuthority` is now fail-closed — the generic - resume route refuses it with 403 until the descriptor opts in with `'any'` - (#5561; zero in-repo executors affected). `api` becomes code-only: the - runtime create door the endpoint matcher could never read answers 403 - `NOT_CREATABLE`, and the `**/*.api.ts` artifact route is untouched (#5488). - `registerHook` refuses an empty object target, and `engine.find` refuses a - formula field in `orderBy` rather than sorting on nothing. -- **The ADR-0045 publish gate gets its own machine-managed key** (#4829), so - `app.hidden` goes back to meaning navigation — and the built-in Account app - stops 404ing for every normal user. Nothing to rewrite by hand: stored rows - carrying `hidden: true` convert to `_unpublished: true` on read, and in place - via `os migrate meta --stored --apply`. -- **More enforce-or-remove, on the surfaces authors actually type.** - `GET /api/v1/notifications` loses the `cursor` half it honoured on neither - side of its contract — a caller paginating by the published shape re-read the - first window forever, both pages parsing green (#6361). `ExportFieldMeta` - sheds eight constraint keys (#6536); `ImportRequest.runAutomations` declares - the default the import route actually applies (#6704); `AggregationNode.distinct` - retires (one face honoured it, five ignored it); `ActionDescriptor.isAsync` - goes as a second spelling of `supportsPause` (#6748); `global_nav` leaves - `ACTION_LOCATIONS`; `manifest.loading` leaves the plugin contract; three - zero-reader SDUI page-component props retire and four more are declared, with - `page:tabs.type` renamed `tabStyle` (#5775, #6776). - -#### New in Console — bundled objectui advanced `7dfbeb704e1e → 0cf8f0f70d10` - -One pin move, released as **@objectstack/console 17.0.0-rc.6**: 66 non-merge -objectui commits, 24 of 24 changesets releasing; the enumeration is -`console-0cf8f0f70d10`. Two entries carry the author's own breaking -annotation, but both are TypeScript -export-surface changes inside objectui's own npm packages — the `GestureType` / -`GestureConfig` rename (objectui#3363), and objectui tracking the -`@objectstack` family at `17.0.0-rc.5` (objectui#3560). `@objectstack/console` -ships a frozen SPA build at the pin SHA and does not forward those type -entrypoints, so **no new metadata migration is registered for this move** — the -changeset records that verdict against ADR-0087. The same changeset also lists, -by subject rather than by count, the 42 commits in the range that carried no -changeset of their own. - -### Landed since 17.0.0-rc.6 - -These changes are on `main` after the `rc.6` cut and **ship in 17.0.0** — the -last window of the train, released by the cut that exited Changesets pre-mode -and published to `latest` on 2026-08-14. The cut released **375 changesets — 11 -`major`-class, 99 `minor`, 265 `patch`**, and none that release nothing, making -it the train's second-largest round behind `rc.6`'s 425 and ahead of `rc.4`'s -360. The window was still open when the entries below were first written -against 374 pending changesets; the one landing between that draft and the cut -is called out under *Metadata, packages & authoring gates*. - -As in the windows above, landings that belong to a section higher up the page -are documented **there** rather than repeated here. - -#### Credentials stop being writable in the clear (#7990, #8082, #8336, #8075) - -The largest coherent story in this window. `sys_metadata.metadata` is served -back by the ordinary data API and a datasource or connector artefact is -persisted whole — so every schema that *accepted* an inline credential was -storing that credential in cleartext at rest. Four doors close, in the order -authors were pushed through them: - -- **The credential keys.** Driver `config.password` (SQL/mongo) and - `config.authToken` (turso) are declared-unwritable: writing one fails `tsc` - (the input type is `never`) and fails the parse with a prescription naming - the replacement. The former alias spellings (`passwd`, `pwd`, `token`, `jwt`, - `auth_token`, `authtoken`) carry the same refusal. On the connector side, - `DeclarativeConnectorEntrySchema` now refuses a non-`none` `authentication` - on **every** authored entry, catalog descriptors included — previously only - provider-bound instances were covered (ADR-0097 §3), so a descriptor could - publish an inline `token` / `key` / `password` / `clientSecret`. -- **The URL that carried the same secret one syntax over.** `config.url` - accepted `postgresql://user:password@host/db`, which landed in `sys_metadata` - cleartext exactly as `config.password` did — and the key refusal itself - steered authors (very often AI authors) into the URL form. A shared - value-level parse now refuses a userinfo segment carrying a non-empty - password across the four URL-bearing driver schemas. A bare username - (`user@host`) stays accepted, so an untouched "Save" on a legacy row still - works, and runtime-environment DSNs (`OS_DATABASE_URL` and friends) are - unaffected by construction — they never pass through the authoring schema. -- **The placeholder escape hatch.** `${…}` syntax in connection-material driver - config keys is refused at publish (#8336). Placeholders in authored metadata - are resolved by nothing and reach the database client verbatim — measured. -- **The two dead families whose only distinctive feature was a credential - sink.** `ExternalDataSource` / `ExternalFieldMapping` / `ExternalLookup` and - `MessageQueueConfig` / `TopicConfig` / `ConsumerConfig` / `DeadLetterQueue` - are removed outright — 8 definitions, 22 exported names — with zero consumers - repo-wide and inline-credential sinks in both (#8075). - -There is deliberately **no automatic rewrite**: moving a cleartext credential -into `sys_secret` requires encrypting it through a running secret binder, which -a source transform cannot do, and auto-deleting the key would silently drop a -live credential. `os migrate meta` surfaces both changes as structured TODOs. -Setup → Datasources already does the right thing — its masked secret field -encrypts into `sys_secret` and writes `external.credentialsRef`; it never wrote -`config`. To make that route declarable, `DatasourceSchema` now accepts -`external.credentialsRef` — and only it — on `schemaMode: 'managed'` (#8588). - -The same sweep hardens what was already stored. The OIDC SSO `clientSecret` is -encrypted at rest; webhook custom `headers` are encrypted rather than riding -`definition_json` in cleartext (#7986); the subscriber's HMAC signing secret is -no longer readable from `sys_webhook` over the data API (#7799) and stops being -persisted on every delivery row (#7722); `sys_scim_provider.scim_token` and -`sys_oauth_application.client_secret` get their posture pinned; the datasource -read path stops serving stored credentials in cleartext; and -`sys_http_delivery`'s callout credentials leave the generic data-API read -(#8118). Two new `internal: true` fields — `sys_api_key.key` (#7728) and -`sys_session.token` — never return on the generic data path at all, and the -aggregate guard refuses `internal` columns rather than only the -`secret`/`password` *types* (#7922). Finally, the credential write door refuses -`""` (#8616): a secret field could be set to an empty credential that every -read then reported as masked. - -**Settings fail closed.** A write that would persist a secret through the -base64 `NoopCryptoAdapter` is refused rather than stored (matching the engine's -posture), encrypted values are redacted at the REST read boundary (#7522), -rotation actually repoints the secret handle and destroys the retired -ciphertext, and the refusal gets a wire spelling — `SETTINGS_CRYPTO_UNAVAILABLE` -— so a client can branch on it (#8273). A webhook holding an encrypted signing -secret re-arms the moment the `CryptoProvider` registers instead of ~60s later -(#8022); one whose stored secret or header map cannot be recovered **parks** -the subscription instead of arming it and delivering unsigned or with the -headers missing (#8542, #8558). - -#### Driver text stops reaching the caller - -A caught driver error was being interpolated into client-facing messages across -the metadata plane, which discloses dialect, schema and sometimes data. The -whole class is closed: the generic message path (#8136), the batch verbs' -`failed[]` `message` and `code` (#8333, #8441), the seed loader's -`errors[].message` (#8442), the package-publish door's `seedApplied` (#8443), -the direct-mount package door's leaky 5xx (#8086), a bulk write's per-row -`errors[].message` (#8502), and the shipped Postgres and bare-SQLite phrasings -of a driver failure. A package publish that faults in the driver is answered as -a server error rather than passing the text through. - -#### Breaking changes since rc.6 - -Beyond the credential doors above: - -- **`view.exportOptions` adopts the object form the renderer actually reads** - (#8010). `ListViewSchema` typed it as a bare format array while the only - renderer reads an object (`exportOptions.formats`, plus `maxRecords`, - `includeHeaders`, `fileNamePrefix`, `streaming`). A project following the - published type wrote `exportOptions: ['xlsx']`, the renderer saw - `.formats === undefined` and fell back to `['csv','json']` — so **no - declaration was both type-legal and functional**, reported from a live - customer project. The same ruling removes `'pdf'` from the format enum: PDF - export was declined platform-side (#1301), so the member was - declared-but-unrenderable. -- **Three pass-through-only list-view keys retire** — `striped`, `bordered`, - `virtualScroll` (#7176). The liveness ledger graded them `live`, but the - citations were forwarding copies, not appliers: the chain ends at - `ObjectGrid.tsx` with zero occurrences of any of the three. Delete the keys — - the grid frame is the renderer's own constant, and large datasets page via - the view's `pagination` block. -- **`field` loses `allowRuntimeCreate`** (#7893). A standalone - `PUT /meta/field/{object}.{name}` answered `200 {"success":true}`, persisted a - row, and the field **never** appeared in the object's `fields`. It now answers - 403 `NOT_CREATABLE` naming the remedy. Adding a field at runtime is not lost: - `object` keeps `allowRuntimeCreate: true`, so write the whole object — what is - withdrawn is a second, broken spelling of the same operation. -- **`engine.update()` loses `upsert`** (#8057) — declared on both update-options - schemas and on the engine's allowlist, read by no engine or driver path. A - caller passing `{ upsert: true }` got silence: not a refusal, not an upsert. -- **`/api/v1/auth/config` stops advertising `passkeys` / `magicLink`** (#7481). - Both flags were served from introduction and read by no client, so the payload - advertised two sign-in methods no user could reach. `AuthPluginConfig.plugins.*` - is unchanged — this narrows the served payload, not the server configuration. -- **`FieldReferenceSchema` leaves the list comparand positions** (#7596). Both - `$between` endpoints carried it and `$in`/`$nin` were `z.array(z.any())`, - which admits a reference — a form no backend has ever implemented. The - in-memory evaluator reads `$field` only off a non-array object, so `$in` - compared the reference *object* against stored values and never matched. -- **`submitBehavior.url` is ruled and enforced** (#7496) — relative-only, - declared-field interpolation, URL-escaped. -- **Cross-tenant uninstall must be declared** (#7780). `deletePackage` selected - rows with `{ package_id }` and added an organization predicate only if the - caller supplied one, so an omitted argument matched **every** organization's - rows — measured during #7705 at 5 of 5 deleted, including a foreign - organization's. A call naming neither an organization nor `allTenants` is now - a 400. -- **The memory driver declares itself single-tenant and refuses to boot - multi-tenant** (#6915). It implements no row-level tenant isolation — it never - reads `DriverOptions.tenantId` — so a multi-tenant deployment backed by it did - not fail; it served cross-tenant reads, updates and deletes silently. -- **`$contains` is case-SENSITIVE on MongoDB and the memory driver** (#6682). - The hardcoded `$options: 'i'` beside the `$regex` is gone from all four arms, - so `{ name: { $contains: 'acme' } }` no longer returns `ACME Corp`. This is - the last driver arriving at #4706 Q2 = A, after #6518 flipped the SQL family. - Both directions of the defect mattered: the fold over-matched — on an RLS read - scope that is over-reach, not merely a loose filter — and it folded the whole - Unicode range. -- **Closed query-parameter sets become REST ingress policy** (#7606), starting - with the first tier of data read routes. Handlers read the keys they knew and - ignored the rest, so a misspelled or invented parameter was silently dropped - and the caller got a plausible `200`. It failed in both directions: a dropped - `?objects=` fans a search across every object and a dropped `?fields=` - returns the whole record, while a dropped key inside a filter answers `200` - with zero rows — each shaped exactly like a legitimate result. Tolerated - traffic breaks here, deliberately. -- **A number field's declared `scale` is enforced — by rejection, never - rounding** (#7501). `scale: 0` accepted `11.5` and stored it verbatim through - both REST create and CSV import. It now answers 400 `VALIDATION_FAILED` with - field code `max_scale`; silent rounding would be silently altering data. -- **Two `sys_audit_log` action values retire** — `export` / `permission_change`, - then `restore` (#8315, #7675) — declared actions with no writer anywhere. - -#### Security corrections since rc.6 - -- **A tenant-scoped write with no active organization is refused**, naming what - is missing (ADR-0123 D2, #8247/#8208) — and a user's first session no longer - predates their membership, so its audit rows carry a tenant (#8245). -- **Uniqueness becomes per-organization wherever it was tenant-scoped.** A - declared unique index that ignored the tenant wall let one organization's row - block another's: `sys_setting` (#8555), `sys_position.name` (#8468), - `sys_user_preference` and `sys_capability` (#8323), five more across - plugin-security / plugin-sharing / plugin-webhooks / platform-objects / - service-messaging (#8554), and two beyond those (#8577). The tenant-scope - index is now declared whenever an object carries `organization_id` — whether - the platform provisioned that column or the author declared it (#8459) — and - `GET /meta/object/:name` serves it (#8375). -- **Sharing and invitation reads get their organization predicate.** A - `manage_sharing` holder with no active organization no longer reads every - tenant's sharing rules (#8158); `getRule`'s by-id branch is scoped to the - caller's organization (#7761); deleting a platform-global sharing rule is - refused to org-scoped callers (#7795); and a plain member can no longer read - the organization's whole invitation ledger (#8095), while a - `delegated_admin` can now read the invitations it issued (#8240). -- **`$expand` no longer discloses records the caller is 403'd from** — the - #2850 expand waiver is removed (#7626). -- **Federated objects stop being scoped by columns they do not have.** The - tenant wall no longer scopes a federated object by a phantom anchor (#7835), - the org-scope predicate is not injected onto external objects (#7738), and - minting a share row on a federated object whose `owner_id` is the platform's - injected anchor is refused (#8119). -- **Two RLS write-side holes close.** A `check`-only write policy no longer - disables both write-side row gates — a caller who could not read a record - could write it by id — and a by-id write target must be inside the caller's - readable set when only select-scope RLS is authored. Permission pre-image - probe faults now propagate instead of reading as absent rows (#7505). -- **`public_read_write` means what it says** (#8023): the object is writable by - everyone the access matrix grants `edit`, not only by each row's creator. - An app-declared permission baseline **composes** with the platform - `member_default` instead of replacing it (#7555), applied additively on - `/auth/me/permissions` and `/me/apps` (#7608). -- **Enumeration oracles closed.** `DELETE /reports/:id` and - `DELETE /reports/schedules/:scheduleId` stop telling a caller whether an id - exists; `GET /meta/app/` reports a permission denial instead of absence. -- **`403 PERMISSION_DENIED` stops handing business users internal authorization - vocabulary** on the object CRUD gate and the row-level/capability refusals, - and the dispatcher stops answering a denial's internal payload (#7450). - `controlled_by_parent` write refusals split by true semantics — three of the - six legs stop answering `PERMISSION_DENIED`. -- **Anonymous deny is consulted before the capability answer** on `/ai/**` - (#7653) and `/security` (#7911). -- **Impersonation takes effect for bearer clients** (#4467/#8049 lane work) — - the caller's token is rotated and `stop-impersonating` recovers the admin — - and `/auth/change-password` clears the force-change flag and enforces - password-reuse on the bearer lane, not only on cookies. -- **A member's stored membership grade is canonicalised at the write** (#8317), - so an org admin can no longer remove an owner; a member can revoke their - **own** API key (#8053); and `member_default` grants owner-scoped read on the - personal inbox (#7344). - -#### Protocol & wire changes since rc.6 - -- **`like` / `ilike` stop being folded onto `$contains` at the wire** (#7536), - and `$search` compiles to `$icontains` so textual search is actually - case-insensitive. -- **A `where` on a virtual `formula` field is refused, not answered with zero - rows** (#8296) — as is an unknown field inside `where` / `$filter` / a filter - AST (#7534), a dotted `fields` / `$select` entry (#7532, which used to widen - the response to every field), and a repeated `?filter=` (#7390). -- **A bulk write that stops now reports every record** — `NOT_ATTEMPTED` rows - instead of a truncated `results` array — and a per-row `errors[].httpStatus` - carries the status its producer declared, in any spelling (#8570). -- **A hook refusal that declares `statusCode` reaches the wire with that - status** rather than `500 INTERNAL_ERROR` (#7525), a crashing hook body - answers the sanitised fault envelope rather than a raw `TypeError` at 400 - (#7543), and one error envelope now spans the record-sharing family (#8111), - the `/security/suggested-bindings` trio (#7981) and the `/security/explain` - pair (#8073). -- **`UNIQUE_VIOLATION` 409s name the conflicting field**, matching the bulk path - (#7821), and malformed flow definitions answer 400 `VALIDATION_FAILED` rather - than 500 on both `POST` and `PUT /api/v1/automation/:name` (#8055, #8123). -- **Five ledgered-but-dead routes are mounted, and the class that hid them is - gated** (#7526); the route ledger gains live-mount parity. -- **Exported `datetime` cells render in the business timezone, not UTC** - (#8373). - -#### Metadata, packages & authoring gates - -- **An authored OWD is required at the runtime object door** (#8600), completing - the #7891 flip — `runtimeTypes` gains `object`, and ADR-0094 R2's - external-wider arm retires. `METADATA_CREATE_SEEDS.object` authors its - org-wide default explicitly rather than leaning on what the runtime resolves. -- **`validateSecurityPosture` gates runtime publishes** for `permission` / `book` - writes (#8309), the ADR-0091 seed pair (#8307) and the `views[]` - visibility-predicate family (#7220). -- **The `object-*` block family enters `ComponentPropsMap`** (#7751), so a - typo'd key inside a data-bound block's `properties` is caught at authoring - time with a did-you-mean. #4001 continues with batch A — the 31 SDUI - component-props shapes — and batch B, the memory driver's `persistence` - sub-shapes. -- **Uninstall stops leaving debris.** It removes the non-object metadata a - package shipped (#7221), no longer orphans env-wide `sys_metadata` rows - (#7705), and a refused uninstall now mutates nothing (#7970). Disabling a - package stops its objects being served, and a failed uninstall stops - answering 200 (#7557). -- **The `field` overlay lock is enforced at the live route** (#7743) — an - artifact-backed field PUT is refused rather than accepted 200 — and an - object's overlay row is treated as a base layer, not its resolved schema - (#8027). -- **A tenant's own rename beats a package extension's scalar** (#8620, - ADR-0029 D9.2a) — the one landing between this section's draft and the cut. - `mergeObjectDefinitions` applied an `objectExtensions` entry's `label` / - `pluralLabel` / `description` last, onto whatever base it was handed — and - D9.2 makes the tenant's overlay that base. So a rename saved through Studio - answered 200, was visible under `?layers=true`, and was overwritten inside - the fold before any read served it. An extender's scalar now applies only - while the fold's base still carries the packaged owner's value; a base that - has diverged was authored by the tenant, and the extender yields. It is the - comparison-based mechanism #8284 established for the i18n catalog one layer - up — the same predicate, now exported from `@objectstack/spec` and imported - by the registry rather than re-spelled — so one rule covers both layers. No - provenance flags and no migration: the question is answered from two values - at fold time. -- **`OS_METADATA_WRITABLE` no longer unlocks a write into a read-only package** - (#8146), and the metadata write refusal stops depending on deployment - topology (#8184) while reporting the package door it hit — - `ITEM_LOCKED` / `WRITABLE_PACKAGE_REQUIRED`. -- **`put().version` identifies the bytes actually stored** — the hash is taken - over the serialized form (#7856). -- **A plural metadata read can say it is known-partial** (#6504), and a - per-type read-path redaction seam lands in `kernel` with one definition of - "what is a credential key" in `data` (#8300). - -#### Drivers, query engine & analytics - -- **`$field` becomes a real cross-field comparison.** SQL push-down compiles it - to a column-to-column comparison (#5222); equality triples lower to - `{ $eq: ref }` (#7597); a field-to-field RLS rule is served on the analytics - path, with native SQL declining and routing to the engine (#7598); and the - comparand is refused on both SQL-lowering doors rather than being bound as the - comparison's *value*. The refusal names the author's own columns without - re-disclosing policy (#8220), and the two compared columns are withheld from a - cross-field refusal that would leak them (#7929, #7988). -- **MongoDB takes a structured `GroupByNode`**, answers `count` / `count_distinct`, - and buckets `dateGranularity` server-side with `supports.queryDateGranularity` - published (#7580); malformed `$between`, undeclared node-level `$`-keys and - `{ field: {} }` are refused (#5346, #5376). -- **An upsert whose `conflictKeys` have no backing unique index refuses - legibly** — in an ADR-0112 envelope rather than a raw `SqliteError` (#8445) — - on Postgres (#8567) and Turso as well as SQLite, with MySQL's behaviour - measured and pinned (#8592). -- **The first concurrent autonumber insert from two tenants no longer fails on - Postgres with `25P02`** (#8269), and the autonumber contract is stated: - unique and monotonic per scope, **not** gapless (#8283). -- **`ObjectQL.delete`'s by-id cascade is one unit of work** (#7413), `expand` - no longer silently no-ops when the nested `fields` omits `id` (#7537), and a - single-record update stops reporting the addressed row's own primary key as a - dropped field (#8093). - -#### Automation, jobs & the audit trail - -- **Automation runs record what triggered them, and keep it across a restart** - (#7533). A durable PAUSED run is visible to `listRuns` and run-detail after a - cold restart (#8050), its variable snapshot is readable on run-detail - (#7639), and a caught `try_catch` failure records what failed, how many - attempts ran and which node threw (#7546). -- **Scheduled and time-relative flows re-bind after a kernel rebuild** instead - of failing permanently, and a metadata reload reconciles declarative - connectors rather than no-op'ing against a stale registry (#7742). -- **Runs that did not do their work stop being audited as success** — a - timed-out job run records `timeout` (#7734) and a run that finishes without - doing its work records `degraded` (#5548). A durable `http` callout is - `unmeasured`, not `acted` (#7882), and a notify summary no longer reports a - delivery the delivery record dead-lettered (#7747). -- **The audit trail gets its missing writers.** Sign-in and sign-out are - recorded with the actor and the tenant (#8144); settings writes reach - `sys_audit_log` as `config_change` (#8145); the whole metadata lifecycle is - recorded, not only `save`; `publishPackageDrafts` writes the rows a batch - publish always owed; and a publish refused by a lock or a 409 leaves its own - audit row, outside the transaction so a rollback cannot take it (#8594, - #8400). A bearer-authenticated metadata write is attributed to the caller - rather than to `system` (#7749). - -#### New backend capabilities since rc.6 - -- **`OS_ARTIFACT_URL` — artifact-pinned boot** (#8368): boot a stack from a - published artifact by reference, with an SRI-style fragment pin. -- **`internal: true`** (#7728) — a field whose value is never returned on the - generic data path, with the write-response guarantee guarded as a property - rather than one class. -- **The `viewItems:` channel** (#5320, #8070) — a portable home for - non-container view artifacts in runtime-assembled manifests, with `views:` - tightened to the declared container-only contract. -- **The `security` service publishes the caller's resolved permission SETS** - (#7616), and `security/explain` gains a batch form — `recordIds: string[]`, - max 200, mutually exclusive with `recordId` (#8326). -- **Author-facing declarations that now hold**: `publicPicker` on - `FormFieldSchema` (#7467), `useGrouping` on `Field.number` (#7768), a declared - currency `precision` checked against ISO 4217 (#7918), `userActions.create` / - `.import` accepting the same CEL-predicate object form as `edit` / `delete` - (#7692), a `flows` surface on `TranslationBundle` for screen-flow wizard copy - (#7646), an optional `subCaption` on the dashboard widget translation node, - and an optional `object` on `GlobalFilterSchema` for i18n label resolution - (#7804). -- **`POST /auth/change-email` works** (#7735) — better-auth's `user.changeEmail` - is configured, with verification — and it notifies the **previous** address - without gating on it (#8019). -- **Verification harness**: `bootStack({ orgContext: true })` gives a harness - admin an organization-carrying execution context (#7762), and `--rls` runs one - probe persona per **declared position** so app-authored narrowing is actually - exercised (#7978) — after a fix for `--rls` reporting 0 HOLES over a probe - that could not reach the class it claimed to prove (#7685). -- **The multi-node gate can carry an admitted node count**, so a license cap - refuses the excess replicas rather than the whole cluster. - -#### New in Console — bundled objectui advanced `0cf8f0f70d10 → 665661ab0932` - -**Three pin moves**, released as **@objectstack/console 17.0.0** — the -enumerations are `console-6314e87f2d49`, `console-6d77acfe3125` and -`console-665661ab0932`: - -| Range | objectui commits | changesets releasing | -| :-- | --: | --: | -| `0cf8f0f70d10 → 6314e87f2d49` | 43 | 27 of 30 | -| `6314e87f2d49 → 6d77acfe3125` | 123 | 99 of 115 | -| `6d77acfe3125 → 665661ab0932` | 82 | 72 of 78 | - -`@objectstack/console` ships a frozen SPA build at the pin SHA and does not -forward objectui's type entrypoints, so **no new metadata migration is -registered for these moves** — each changeset records that verdict against -ADR-0087, and each lists by subject (not by count) the commits in its range -that carried no changeset of their own. - -The through-lines across the three: **the display locale finally reaches every -channel** — dates, currency, percentages, gantt and timeline labels, dashboard -and report measures, the null-value bucket, the Settings namespace screen, and -the organization/invitation console, closing the long tail where a `zh` session -read half its output in English. **Inspectors block Save on CEL parse faults**, -so a hook guard, action predicate, validation rule, page-block condition or -formatting rule that does not parse no longer saves and publishes as the live -definition. **The editable dashboard grid renders dataset-bound widgets — and -says so visibly when it cannot**, instead of a silent blank chart. And the -permission matrix models the server: it honors `allowRuntimeCreate`, names the -gate that tripped in its read-only badge, and models the artifact tier so a -Save cannot 403 on a code-declared set. - -Notable single fixes: impersonation takes effect at all on the data lane -(`set-auth-token`, #4467) with a standing banner and a loudly-failing exit; -cross-page "select all N matching" replays the host's real query or abstains -rather than fanning out unfiltered; lookup values link to the referenced -record; an `OBJECT_API_DISABLED` list renders an honest cannot-work state -instead of the empty state; `deleteView` removes every home a view has; -`exportOptions` follows the spec's object form with a legacy bare array lifted -by the bridge; and `crypto.randomUUID` is restored on insecure origins so list -views stop crashing on LAN IPs. - - ---- - -## What's new in 17.1.0 - -17.1.0 was published to the `latest` tag on **2026-08-20**, six days after the -17.0.0 GA. It is a large minor: the version-locked train moved **69 packages**, -carrying **111 minor and 203 patch changelog entries and no major**. The bundled -Console advances two pins, `665661ab0932 → 82a94170c405 → 9a3daf8d37ad`. - -⚠️ **Read this before treating the version number as a safety guarantee.** A -minor adds capabilities without breaking existing metadata or code — but several -changes here landed *after* the 17.0.0 cut and ship as `minor` under the lockstep -launch-window convention while being explicitly breaking, and they say so in -their own changelog entries: the export-wildcard withdrawal (#8681), the strict -close of the top-level stack surface (#8687), the dashboard `modal` target -narrowing (#9013), and the list-view `sort` refusal (#9257). Two more — -`sys_permission_set.active` / `sys_position.active` (#8613) and the sharing-rule -recipient filter (#8710) — change who can read and write on **live data with no -migration step to notice**. The 17.1.0 upgrade checklist below is not optional -reading for this release. - -The practical theme is *honest refusals*. Where 17.0.0 made the authorable -surface loud, 17.1.0 makes the runtime's answers loud: a flag that promised to -revoke access now revokes it, a read that failed stops being served as an empty -one, a flow that never dispatched stops being reported as a run that failed, and -a credential that was never meant to be readable stops being served. - -### New capabilities in 17.1.0 - -- **Partial field masking (#8993).** `FieldSchema` declares `maskingRule` — the - 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 change (ADR-0049 declare = enforce). A - field declaring a rule is served masked-but-recognisable to every non-system - caller, and the field's `requiredPermissions` (ADR-0066 D3) is the unmask gate. - 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 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` rather than silently overwriting the stored value. New - exports: `FieldMaskingRuleSchema`, `FieldMaskingKeepSchema`, - `FIELD_MASKING_PRESETS`, `maskFieldValue`, `MASK_CHAR`. -- **Record-view auditing.** `sys_audit_log` covered writes only, so *who viewed - this customer record, and when?* had no answer short of custom work. The ledger - gains a `read` action, its writer, and the `record_views` list view. Scope is - deliberately narrow: **record-detail reads only** (the read 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 with no global switch** - (`new AuditPlugin({ readAudit: { objects: ['contact', 'account'] } })`; an - empty opt-in registers no hook, so the default posture costs a read nothing), - and **batched off the request path** — rows persist on a later tick and keep - the instant the record was viewed, not the instant its batch drained. The row - records who, what and when and **never field values**: read auditing runs ahead - of field masking, so copying values in would mint a plaintext copy of exactly - what field-level security withholds. A system-elevated read (`api.sudo()`, - formula recomputes, roll-ups) and a read with no principal both write no row. -- **Read-only approval visibility, per object, default OFF (#8652).** - `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. Omitted or empty leaves visibility exactly as - it is, so an existing deployment sees no change on upgrade — but **on an object - you list this is not a no-op**: a ledger keeper or supervisor who holds full read - but never appears in the approval used to get `200` with an empty list, and now - sees the request row (including its `payload` snapshot), every actor and - decision, **the action's comment text** (意见正文), and decision attachments. - Enable it where the approval commentary is meant to be seen; it is per object - precisely so enabling it for a ledger object does not enable it elsewhere. The - tier is read-only (`can_act: false`, and no approval action is delivered - through it), introduces no new permission concept — the service reads the - record **as the caller**, so ordinary CRUD and RLS decide — and leaves an - untargeted inbox list unchanged, so a work queue does not become a browse - surface. -- **`os migrate duplicates` (#8928).** An operator-facing, read-only inventory of - the business identifiers the tenancy split already minted twice. It reports one - row per duplicated value with its holders (id, organization, partition, - creation timestamp), the live condition (an object still running a `__global__` - counter beside an organization-scoped one), and a `skipped` list carrying the - driver's own message so an unreadable target never reads as a clean one. JSON - on stdout, no persistence, no new schema, and it applies nothing — no DDL, no - seed. ⚠️ **Run it before the #8686 backfill:** `organization_id = NULL` is the - marker that says the row came from the untenanted side, and the repair - overwrites it. Reporting is all it does — renumbering an already-minted - identifier stays deliberately out of scope. -- **Operator-initiated re-homing of stored cleartext datasource credentials - (#8155).** A datasource row created before the write door closed can still hold - its credential in cleartext inside `config`; the read paths were closed, but - nothing removed what was already at rest. `IDatasourceAdminService - .migrateCredential(name)` — the Setup action *"Move credential to the secret - store"*, backed by `POST /api/v1/datasources/:name/migrate-credential` — writes - the secret, **reads it back and compares**, and only then binds - `external.credentialsRef` and drops the inline key in one record write. Per - datasource, initiated by an operator, never a sweep and with no batch spelling. - It is idempotent, and it refuses rather than guesses: a credential embedded in a - connection URL, an alias spelling no connection builder reads, turso's - `encryptionKey`, and a code-defined datasource are each refused with a reason, - and any credential-shaped key left behind is named in the result so "migrated" - never reads as "this row is now clean". -- **A hook refusal can mark its own message user-facing — `userMessage` (#9934).** - The console form deliberately discards the server `message` on 403 and - substitutes a generic string, which also suppressed every deliberate, localized - refusal an application hook author wrote. Setting `userMessage` on the thrown - error at throw time marks that text as user-facing. It is a text-carrying field - rather than a boolean beside `message`, so no boundary that rewraps or - substitutes `message` can promote platform prose into the marked channel, and - platform/driver code never sets it. `ApiErrorSchema.userMessage` and - `EnhancedApiErrorSchema.userMessage` are additive and optional; unmarked errors - produce byte-identical wire bodies. The SDK attaches `err.userMessage` from both - wire dialects. -- **`ActionSchema.onSuccess` — post-success navigation (#9566, #9474).** One - contract for both server-executing action types: `onSuccess: { navigate, openIn? }`, - 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). - `navigate` is a route/URL template whose interpolation scope gains - **`${result.*}` — the action's server response payload** — which is what makes - "server clones a record, jump to the new record" declarable: - `navigate: '/apps/crm/tasks/${result.id}'`. `openIn` is the closed enum - `'self' | 'newTab'`, defaulting `'self'`. The existing handler-return convention - (`{ redirectUrl, openIn? }`) keeps its 17.0.0 semantics, so no shipped handler - silently flips behaviour. -- **Localized email templates reach the inbox and flow `notify` nodes.** - `IEmailService` gains a render-only - `renderTemplate({ template, locale, data, timezone }) → { subject, html, text }`, - and the inbox channel consumes it, so localized `sys_email_template` content now - reaches `sys_inbox_message` (#9225). Flow `notify` nodes can reference a template - for localized delivery — `template` + `templateData` on `NotifyNodeConfig`, - resolved by `(name, locale)` at delivery time (#9205). **The locale is one - value for the whole notification, not one per recipient**: `payload.locale` if - the producer set one, else the deployment default - (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so - recipients whose personal languages differ all receive the same row (a - per-user locale is deferred by the 2026-08-13 ruling). -- **`map` joins the per-visualization blocks on `ListViewSchema`.** - `ListMapConfigSchema` is the eighth block alongside kanban / calendar / gantt / - gallery / timeline / chart / tree, declaring the map renderer's documented read - surface: `latitudeField`, `longitudeField`, `locationField`, `titleField`, - `descriptionField`, `zoom` (1–20) and `center`. All keys are optional and none - carries a default — with no camera declared the renderer fits the camera to the - queried records. Because `ListViewSchema` is strict, a `type: 'map'` view could - not declare its field mapping at all before this, so any marker title field - other than the renderer default `name` was unreachable. -- **`icontains` joins the view and infix filter vocabularies (#8934).** The - operator has been executable on every driver since #5702/#6520 but was - authorable from only one of the three filter dialects. `VIEW_FILTER_OPERATORS` - and `AST_OPERATOR_MAP` gain the canonical spelling. The boundary is preserved - deliberately: `icontains` (LIKE-escaped substring — a comparand `%` is a - literal) and `ilike` (raw LIKE pattern) are **not** aliases of each other, and - there is no `not_icontains`, because the authoring vocabularies mirror the - executed set rather than widening it. -- **Field-level `relatedListFilter` (#8704).** The field-level related-list family - (`relatedList` / `relatedListTitle` / `relatedListColumns`) gains a fourth - member, 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. It carries the canonical Query-DSL - `FilterCondition` — no new dialect — and is AND-composed with the - parent-relationship condition as an authored constraint, with the tab badge - count honouring the same composed filter so counts match visible rows. - ⚠️ The consumption half (objectui#4664) had not landed at this pin: the key is - ledgered `planned` and carries an author warning until it does. -- **Stamp-only `tenancy.organizationField` (#8778).** The platform could say what - an object is *walled* by (`tenancy.tenantField`) but not which column says who a - row is *about* — which for credential tables deliberately differ. - `TenancyConfigSchema` accepts an optional `organizationField` that is - **read-neutral and stamp-only**: the audit writer consults it, and - `applyTenantScope`, `injectTenantOnInsert`, `computeTenantLayer0Filter` and - `resolveInjectedSystemColumns` are measured blind to it, pinned by tests. - Declaring it never walls an object and never hides rows. `sys_api_key` now - declares it, so revoking a key from a different active organization lands the - audit row behind the wall of the *key's* organization. -- **API keys are minted against the minter's active organization (#8287).** Under - `OS_TENANCY_POSTURE=isolated` a minted key could read **nothing at all**: - `sys_api_key` carried no organization column, so key authentication established - a user but no active organization, and every organization-scoped read answered - `200` with `total 0` while the console went on offering minting. `sys_api_key` - now declares `active_organization_id`; `POST /api/v1/keys` **inherits** the - caller's active organization (there is deliberately no org parameter and no - cross-org key) and re-checks `sys_member` membership at mint time. An - ex-member's key now fails closed at verify time rather than degrading to a - user-only principal. ⚠️ Pre-existing org-less keys are **never backfilled** — - they keep working under `single` and `group`, and are refused under `isolated`, - where they are provably dead today. -- **`storage` becomes the canonical `CoreServiceName` slot (#9683).** - `file-storage` was the only member whose spelling diverged from its documented - accessor (`services.storage`). `storage` is now canonical; `file-storage` stays - an accepted, **deprecated v17 alias** — `@objectstack/service-storage` registers - the same instance under both names, and discovery reports the canonical key and - mirrors the row verbatim under the alias for its v17 lifetime. A custom v17 - provider for this slot should register both names. -- **Smaller authoring additions.** `Field.time` closes the temporal-builder gap — - `'time'` was a fully declared `FieldType` with no builder, splitting the three - temporal types two-and-one (#8656). `placeholder` becomes a declared - `FieldSchema` key, ending the preview-renders/save-422s trap where four shipped - objectui render surfaces applied the key and `PUT /api/v1/meta/object/:name` - refused it (#9019). `security.explain()` accepts the `recordIds` batch spelling - (#8480). `IHttpServer` gains an optional `afterResponse` response-observing - hook. `GanttConfigSchema` declares `viewMode`. - -### Security corrections in 17.1.0 - -The largest cluster in this release, and the reason its minor version number is -not a safety guarantee. - -- **`sys_permission_set.active` and `sys_position.active` now actually stop - granting access (#8613).** Both objects ship a Deactivate action whose - confirmation dialog promises, in all four locales, that access stops. 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. `resolveAuthzContext` / `resolveUserAuthzGrants` now drop a - deactivated row **before** any derivation, so a deactivated position - contributes no grants and its name leaves `positions`, and a deactivated - 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). Both tables were already read at that seam, so - this costs zero new hot-path queries. A row whose `active` column is **absent - or NULL is unaffected** — the predicate is "explicitly deactivated", never - "explicitly active". Enforcing the flag also opened a one-click, - installation-wide lockout (deactivating `admin_full_access` un-makes every - platform admin at once, and re-activating requires the permission the click - just took away), so the last-administrator guard now judges that write like the - delete and rename spellings it already refused. -- **A deactivated `sys_position` stops conferring sharing-rule record shares - (#8710).** #8613 closed the authorization *derivation* seam; a sharing rule - reaches users by a second road that never passes it, so a rule sharing records - with `cfo` kept sharing them after `cfo` was deactivated. Maintainer ruling, - 2026-08-15, verbatim: **"Access-conferring paths filter deactivated positions; - addressing paths do not."** A rule whose recipient is an explicitly deactivated - position now expands to nobody, and the shares it had already materialised are - **revoked on the next reconcile**. Deliberately unchanged, per the same ruling: - approval **routing** keeps reading the raw directory (filtering there is - fail-open — an approval step routing to nobody), and write gates and - blast-radius reads stay unfiltered, because dropping a deactivated row there - would make a refused binding permitted — access *widening*. -- **The shipped admin permission sets no longer grant export on the `*` wildcard - (#8681).** Measured on 17.0.0 GA across 40 export probes, 5 principals and 8 - objects with real bearer tokens: an org owner exported `crm_quote`, - `crm_campaign` and `crm_task` with `200` and full data, while no app permission - set granted export on any of them and the app had no way to say no — the - wildcard lives in code-package metadata, so editing it answers `403 - [not_overridable]`. This was never a gate defect; the same run proves the gate - exact for every other principal. READ is untouched — this narrows bulk egress - only, and `allowExport` on a `'*'` entry remains a supported authoring shape in - an app's own sets. -- **Credentials and secrets stop being served.** The metadata read path no longer - serves stored cleartext credentials (#8154), and `GET /api/v1/meta/:type/:name/diff` - stops serving stored credential values. `sys_account`'s OAuth access/refresh/id - tokens and its `password` / `previous_password_hashes` stop serializing on the - data API (`internal: true`). Custom email headers stop being served through the - generic data-API read of `sys_email` (#8149). -- **Doors that answered anonymously now require a principal.** The - external-datasource federation HTTP family requires an authenticated caller on - every route (#9686); the datasource-admin family requires authentication - (#9391) and then the `manage_platform_settings` capability; - `POST /meta/:type/:name/publish` and `.../rollback` require `manage_metadata` - (#8919); the four mutating `install-local` routes require `manage_metadata` and - drop the `x-user-id` header fallback, and the `install-local` listing requires - an authenticated principal; and the four `/admin/sso/*` bridges run the - ADR-0068 platform-admin gate before delegating into better-auth. The REST and - endpoint-route 401 anonymous-deny bodies now carry `code: "UNAUTHENTICATED"` - alongside the existing `error` / `message` keys (#9487, #9823). -- **Audit attribution is the authenticated identity.** On all five `/meta` write - doors the recorded actor is now the authenticated identity and the `X-Actor` - header is ignored. Audit rows are stamped from the record's own organization - rather than the actor's active one (#8707), REST `/meta` write doors carry the - caller's organization so rows are no longer stamped environment-wide, and the - metadata audit read is scoped to the caller's organization (#8747). -- **Caller values stop reaching the server log.** A policy-authored - `INVALID_FILTER` refusal withholds the target field (#8197); four more - diagnostic families stop logging the caller's value, measured off live MySQL 8.0 - and PostgreSQL 16; a caller value containing `" - "` no longer eats the - diagnostic's template head; MySQL duplicate-entry reports keep the value out; - and `publish-drafts` no longer discloses driver or subscriber text on - `unhideError` / `rebindError` (#8516). -- **Two ADR-0055 `controlled_by_parent` corrections.** The master-editability - check consults the same app-authored write widener the by-id path does, and - detail writes compose the master's ownership floor the same way a direct write - does (#8865). ⚠️ The first of these is flagged in its own entry as **widening** - who may write rows that are refused today. An insert that omits a required - master-detail parent now answers `400 VALIDATION_FAILED` with `fields[]` rather - than a security-shaped refusal, and security explain reports partial masking as - a third state instead of calling gated fields hidden. - -### Behavior changes & fixes in 17.1.0 - -#### The flow doors answer real HTTP statuses - -- **The automation `trigger` routes** now answer `409` `FLOW_DISABLED` for a - disabled flow and `422` `FLOW_NO_START_NODE` for a definition with no start - node, instead of `200` wrapping an inner `{success: false}`. `AutomationResult - .code` gained the two members deliberately — rather than the transport guessing - from message text — and they carry **no** `status`, which is what lets a - transport tell a never-dispatched exit from a run that dispatched and failed. - Two statuses and not one because a disabled flow is reversible operational - state (`409`) while a definition with no start node cannot be executed as stored - and no retry helps (`422`). -- **`POST /api/v1/actions/:object/:action`** answers that same table instead of - one blanket `400 FLOW_FAILED` (#9446), and **declared `type: 'flow'` endpoints** - join it (#9462) — that door used to answer `200` for every outcome with the raw - engine result in `data`, so a client branching on HTTP status read every - failure as a success on the surface an app publishes as its own public API. An - `outputMapping` is no longer applied to a failure, closing a hole where a - refused dispatch could be presented as data. All three doors now read one - definition (`packages/runtime/src/flow-dispatch-status.ts`). -- **The resume route closed its envelope.** `POST /api/v1/automation/:name/runs/:runId/resume` - refuses a body carrying an unknown top-level key — the accepted set is exactly - `inputs`, `variables`, `output`, `branchLabel` — with `400 VALIDATION_FAILED` - naming the offending keys. A body like `{"nodeId":"ask","values":{…}}` used to - answer `200 success:true` with the screen submission treated as empty: the run - completed and the submitted value never reached the flow. A sibling change - refuses an accepted key carrying a value of the wrong type. -- A flow ACTION that ran and failed now carries the flow author's `errorMessage` - and the run `summary` in `error.details`, and a triggered run carries the - author's `successMessage` / `errorMessage` through `execute()` and both retry - exits. - -#### A failed read stops reading as an empty one - -A recurring class this release closes in several packages at once: a read that -FAILED was indistinguishable from a read that legitimately found nothing. - -- **Four metadata-protocol seams (#8896).** `SeedLoaderService.loadExistingRecords()` - returned an empty `Map` — which is not a cache but the write decision, so the - upsert pre-load turned every update into an INSERT and `bulkWrite`'s retry - recheck was silently disarmed. `searchAll()` reported `totalObjects` / - `totalHits` / `truncated` as though a partial scan had been complete. - `findReferencesToMeta()` dropped whole source types from the list that answers - "what would break if I delete this", so a short list read as "nothing depends on - it". `publishPackageDrafts()` pushed a **fabricated** revert-plan entry whose - `existedBefore: false` means "revert = soft-remove", so reverting that commit - would DELETE 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 shared `isMissingTableError` predicate, so an unprovisioned table - stays benign and everything else surfaces. -- Siblings across the tree: `getMetaDiagnostics` stops publishing an unreadable - store as "0 problems" (#8855); `auditMetaItem` stops reporting a failed audit - read as an empty trail, and a missing `auditMetaItem` / `findReferencesToMeta` - capability is refused rather than answered as "no audit trail" / "nothing - depends on this" (#9426, #9326); `diffMetaItem` stops serving a history outage - as an empty diff (#8868, #8833); a failed `sys_organization` probe stops reading as - "this install has no organizations" (#9261); the roll-up summary index's failed - read is never cached as an empty index (#9154); a cascade-delete dependents - probe that FAILS no longer skips the referential guard; `ObjectQLPlugin`'s three - registry reads stop inventing an empty registry (one silently skipped schema - sync); a failed governance row-count probe stops being indistinguishable from a - quiet object; and `GET /api/v1/meta/:type` refuses a type name that names - nothing instead of serving it as an empty collection (#9488). - -#### Author-time gates reach the runtime publish door - -Rules that only `os build` / `os validate` ran now also judge a runtime write, so -Studio and the metadata API cannot land what the CLI refuses. - -- **Dashboard writes are judged by `validateWidgetBindings` (#7529)** — a widget - bound to a dataset that resolves to nothing was previously a `200` on the - publish door. All six of the rule's error-tier findings gate a publish as one - reference-integrity class. -- **The five gating object rules cross the publish gate**, so an `object` write - through the metadata API is judged by the same completeness, managed-`apiMethods` - and autonumber rules `os build` runs. -- **The publish gate judges a package write against that package's own closure - (#9612)** — it used to hand every rule the tenant's entire `objects` collection, - asking a question nobody wanted answered. The unit is now the package plus the - transitive closure of its **declared** `manifest.dependencies`, plus platform - objects unconditionally, plus rows carrying no package provenance. A write that - names no package narrows nothing and is judged exactly as before — an - unresolvable package buys a write *more* validation input, never less. -- **The publish door reports the runtime authoring gate's advisory findings** - (#9176), and `publishPackageDrafts` (Studio's "publish whole app") reports them - too; the batch publish response gained a spec contract (#9406). -- **New authoring-time refusals.** A dashboard header `modal` action's target - resolves against declared PAGES only (#9013) — `os validate` previously blessed - exactly the buttons the runtime refuses. A sharing rule anchored where sharing - has nothing to widen (a `public` effective sharing model, or a master-detail - detail) is an error, with separate ids because the repairs differ (#9698). A - list-view `sort` naming a formula field, or no field at all, is refused (#9257). - A bare date-range preset name in an ordering filter comparand is refused at - publish (#8793). Three write-surface rules ask provenance, not just membership, - before exempting a system column (#8663), and the same provenance question is - asked at a fifth blanket-`SYSTEM_FIELDS` read site, `searchableFields` (#8404). - -#### The authorable surface closes further - -- **Unknown top-level stack keys are refused (#8687)** — `ObjectStackDefinitionSchema` - was the last strip-mode surface of the #4001 campaign. Measured on 17.0.0 GA, - three injected bogus top-level keys added **zero** warnings to `os validate` and - exited 0, even under `--strict`. The failure population is a typo or stale key - (`flow` for `flows`, `approvalProcesses` after its removal) shipping an artifact - with a whole metadata family absent at runtime. Near misses carry a did-you-mean, - and curated prescriptions answer the known retirements (`storage`, `approvals`, - `workflows`, `portals`, `onDisable`). `onEnable` is now declared rather than - undeclared-but-honoured. -- Undeclared keys are refused on the **analytics authoring surface** (#4001 batch - D), on object **`indexes[]`** entries, on **`Field.inlineColumns` / - `Field.relatedListColumns`** (#9227), and on **`record:alert` / - `record:quick_actions` / `record:history` / `record:discussion`** (#8744) and - **`record:reference_rail`** (#8691) in `ComponentPropsMap`. Malformed field - `scale` / `precision` declarations are refused at authoring time (#8321), and the - closed vocabulary of expression-bindable text keys is declared (#9599). -- **ADR-0049 retirements.** The `element:filter` element retires at element grain - (#9220); the inert `targetVariable` key leaves `element:text_input` and - `element:record_picker` (#9198); `MetadataPluginConfig` loses the inert - `additionalTypes` (#8586); `ApiKeySchema` retires — the identity module stops - publishing a second, fictional declaration of `sys_api_key` (#8715); - `sys_user_permission_set` retires the `delegated_from` column (#9730); and - `BATCH_PARTIAL_FAILURE`, `BATCH_COMPLETE_FAILURE` and `TRANSACTION_FAILED` leave - `StandardErrorCode` (ADR-0112 amendment, 2026-08-18). - -#### Datasource credentials reach the driver - -- A bound `external.credentialsRef` now reaches the **mongo** client on a URL - branch, the **mysql** client on the DSN branch (#8696), and the **postgres** - server on the DSN branch, not just the knex config (#8873). A mysql datasource - that declares TLS now gets it, on both branches and in the spelling `mysql2` can - read (#8874). -- Publish-time refusals close the authoring half: a credential in the mongo - options passthrough (`config.options.auth.password`, #9040); - credential-bearing URL query parameters (`?authToken=` / `?password=`, #8337); - a postgres `config.url` that `pg` itself cannot parse (#9091); `${…}` placeholder - syntax in memory `persistence.path` / `persistence.key` (#8495); and the - contradictory pairs "`credentialsRef` bound + a composed mongo config naming no - `username`" (#9147) and "`credentialsRef` bound + a mongo `config.url` naming no - user" (#9041). - -#### Drivers, query engine and analytics - -- **One unresolvable WHERE column, one answer.** `find()` and `count()` both - refuse with `INVALID_FILTER` / 400 naming the column, and **MySQL joins the - predicate** with the full refusal envelope and recoveries (#8926). -- **MySQL upserts stop merging onto rows the caller never identified.** An upsert - whose `conflictKeys` no PRIMARY KEY or UNIQUE index backs is refused (calls that - previously "resolved" now fail loudly), an upsert whose named conflict target - another UNIQUE key can absorb is refused (#8755), a merge that would rewrite the - row's primary key is refused and rolled back (#8622, #8807). -- **MySQL metadata locks stop hanging.** A blocked `os migrate` refuses in 120s - instead of hanging effectively forever (#9354), and boot schema-sync's widening - ALTER bounds its lock wait too — a blocked boot warns and carries on, and an - un-run widening ALTER is reported at `error` naming the fix. -- **Cascade delete and multi-value references.** `cascadeDeleteRelations` probes a - `multiple: true` reference field with a spelling its storage can answer; the - required-FK escalation on a `multiple: true` lookup is judged **per row**, so a - parent delete is refused only over the rows that actually hold it; - `deleteBehavior: 'set_null'` on a `multiple: true` reference removes the deleted - **member** from the stored array instead of nulling the whole field; and the - delete-cascade path's registry reads propagate instead of answering "no - relations" (#9002). -- **Undeclared fields are refused at the schema** on insert (#8682) and on update - before `beforeUpdate` hooks run (#8738). `[]` no longer satisfies `required` on a - multi-value field (#9476). A TRUE `readonlyWhen` no longer strips hook-derived - values — the conditional strip judges only API-boundary callers (#9107). A - temporal filter comparand the platform cannot interpret is refused at the engine - door instead of answering `200` with zero rows, and a scalar `in`/`nin` - comparand no longer reaches a driver (#9228). -- A dotted filter key whose head is a relation, a formula or a plain scalar is - refused **at both doors** (#8371), and unscoped `multi: true` write - refusals are restored through the wired engine on `sys_attachment` (#9719) and - `sys_comment`. - -#### CLI & developer experience - -- **`objectstack init` scaffolds now compile (#9666).** `init … --install` - reported `✓ Scaffold validated` and the next documented step, `npm run dev`, - failed to compile: the CLI's own shipped template was refused by the CLI's own - shipped rule set, because the `app` and `plugin` templates declared no - `sharingModel` (OWD). Both templates now declare `sharingModel: 'private'` — the - rule's recommended default and the ADR-0090 D1 baseline — and `init`'s self-test - now runs the author-time rule registry over the generated project instead of - only checking that the config loaded. The rule set is the `build` one, the same - set `os dev` reaches, so this is a shift-left rather than a stricter bar. -- **The ready banner names what actually booted (#8978).** On an `OS_ARTIFACT_URL` - boot the `objectstack.config.ts` in cwd is deliberately never executed, but the - banner's `Config:` row still printed it; on the plain artifact-fallback path it - named a config file that does not exist on disk at all. `serve` now reports the - resolved artifact's already-redacted `display` string in an `Artifact: … - (OS_ARTIFACT_URL)` row, omits the row where no safely-redacted value is in hand, - and reports the authored config exactly as before on an ordinary config boot. -- **`os start` / `os dev` stop writing `OS_ARTIFACT_PATH` into the child `serve` - environment (#8985).** Both supervisors handed the resolved path down through - the same variable an operator sets, before the downstream `objectstack.config.ts` - is evaluated — so a config could not answer *"did a human ask for this, or did - the CLI put it here?"*. The path now travels on `OS_INTERNAL_ARTIFACT_PATH`, a - channel the CLI owns both ends of, restoring the property that the presence of - `OS_ARTIFACT_PATH` in a config's environment means an operator set it. Nothing - about resolution changed: - `--artifact > OS_ARTIFACT_URL > OS_INTERNAL_ARTIFACT_PATH > OS_ARTIFACT_PATH > /dist/objectstack.json`. -- `os init` template descriptions stop advertising metadata kinds they never emit - (#9737); scaffolded projects stop describing themselves as the blank template - (#9263); the scaffolded Dockerfile pins the runtime image to the CLI that builds - the artifact instead of `latest` under a comment saying to pin (#9017); the blank template's `specVersion` - stops shipping eleven majors stale (#9264); and the five remote content templates - retire from the scaffolder's catalog. -- `os migrate meta --from N` can finally open the retired-key sources it exists to - rewrite (#9418); `os meta resync` explains a nonzero skip count instead of - leaving it to look like a no-op (#9184); `serve` warns when the declared replica - count exceeds the licensed node cap — ⚠️ deliberately advisory wording, because - nothing is actually refused yet (#8504); and `objectstack serve` registers - `ObservabilityServicePlugin`, so `observability:metrics` resolves for every - consumer following the canonical resolution chain. - -#### Observability, packaging and published docs - -- `http_requests_total` and `http_request_duration_ms` are emitted from the - transport seam, so every inbound mount is counted (#9650). `/discovery` - describes the request's environment rather than the control plane (#9292), and - its `mcp` advertisement follows the request's environment too. A zero on - `cache_*` is documented as meaning "no configured consumer", not "no cache - activity" (#9954). -- The plugin-mounted Hono error paths answer the declared envelope — six refusal - bodies stop speaking the pre-#3675 dialect (#9364) — and the hono adapter's and - the dispatcher's two discovery bodies join the response envelope (#9436, #9813). -- A broad published-README accuracy sweep: five service READMEs, four more - READMEs, `driver-sql`, `mcp` and `plugin-audit` stop documenting symbols, APIs - and call sites that do not exist (#9532, #9544, #9867, #9579), and published - READMEs link to the docs site in the one form that works on npm, on GitHub and - on the docs site (#9632). The better-auth family moves off the `1.7.0-rc.2` - prerelease onto stable `^1.7.1`. - -### New in Console (Studio) — objectui pin `665661ab0932 → 9a3daf8d37ad` - -Two pin moves in this release (`665661ab0932 → 82a94170c405`, then -`82a94170c405 → 9a3daf8d37ad`). Notable declared changes: - -- **Breaking (published API).** `app-shell` is no longer a component key; - `NavigationRenderer` no longer accepts `resolveGroupLabel` / `resolveItemLabel` - (app-navigation localization is owned by the server-side `/meta` boundary); - `` retires the legacy `description` prop, leaving `subtitle` as the - only spelling; `dashboardComponents` is re-keyed from 11 PascalCase component - class names to the 8 schema `type` keys the package actually registers; and the - structured `confirm` object on actions is retired in favour of `confirmText` - (objectui#4314, ADR-0049). -- **Modal targets name pages.** Both of objectui's copies of the prefix - convention retire — a `type: 'modal'` action's string `target`, and a dashboard - header `modal` action's `target`, each name a PAGE only. This is the renderer - half of the `os validate` narrowing above; the two repos now agree. -- **Declared surfaces catch up to the spec.** The five `@objectstack/spec` 17.0.0 - keys the renderers already honoured are published so authors can discover them; - `element:text.content` and `element:button.label` declare the inline - translation map they already accept; `ComponentInput.type` can declare a union; - `ObjectMetricWidgetProps` speaks `I18nLabel` on `label`, `trend.label`, - `description` and `title`; `ObjectMapSchema` declares what ObjectMap reads, with - the `map` block outranking the flat spelling; `AuthInvitation.status` becomes - the closed four-member union it always documented. -- **Keys that declared nothing are settled.** `AccordionItem` and - `ToggleGroupItem` each retire `icon` and wire `disabled`; `VectorFieldMetadata - .indexed` / `.distance_metric` and `BaseFieldMetadata.indexed` are removed along - with the field designer's `Indexed` toggle, because the ObjectStack spec has no - field-level index flag; and the retired `striped` / `bordered` / `virtualScroll` - list-view surface is removed. -- **Rendering and data fixes.** `FormSchema.onChange` — a declared callback the - form renderer never invoked — is called (objectui#4259); an option colour - authored as an explicit hex renders as authored instead of being quantized to - nine palette families (objectui#5141); `ObjectGrid`, `bridgeListView` and - `ObjectDataTable` read the declared column spelling and resolve column identity - before handing columns to the table; form-field type resolution no longer falls - back to `ui`-namespace SDUI node renderers; a top-level `conditionalFormatting` - no longer reaches the kanban view; `DatasetReportRenderer` stops reading - `filter` as an alias for `runtimeFilter`; a clicked cartesian mark names its own - series and the drill title reads its label; and a null second-dimension group is - drawn rather than carrying its measure invisibly. -- **Navigation and shell.** A form's ruled `submitBehavior.url` redirect can be - performed by the HOST, so a destination stays inside a console mounted at a - sub-path (objectui#4989); the declared nav `runAction` slot is consumed and the - private `?runAction=` string convention retires; and the current organization - shows in the top bar for users with exactly one membership. - ---- - -## What's new in 17.2.0 - -17.2.0 was published to the `latest` tag on **2026-08-23**, three days after -17.1.0. The version-locked train moved the same **69 packages**, carrying **204 -distinct changelog entries and no major** — **19** of which mark themselves -BREAKING. (Counted across the 69 package `CHANGELOG.md` files; an entry that -lands in several packages is counted once.) The bundled Console advances one pin, -`9a3daf8d37ad → 190fbd01d061`. - -⚠️ **Read this before treating the version number as a safety guarantee.** As -with 17.1.0, several entries here landed after the 17.0.0 cut and ship as `minor` -under the lockstep launch-window convention while being explicitly breaking — -they say so in their own changelog entries. The theme is the same one 17.0.0 and -17.1.0 established, one surface further in: **a write that declared a condition -nobody evaluated stops reading as a working conditional write**, and a -declared-but-unenforced authorable key is removed rather than maintained. - -### Breaking changes & migration in 17.2.0 - -#### Two write-path guardrails close the "silently dropped predicate" hole (#11009, #11142) - -The by-id dispatch routes to `driver.update(object, id, …)` / -`driver.delete(object, id, …)`, which bind **only** the primary key — every other -`where` key was discarded with no diagnostic. A compare-and-set written as -`{ where: { id, status: { $in: [...] } }, multi: false }` therefore evaluated to -nothing and the write landed unconditionally, reading exactly like a working -conditional write (`8cc8401`). - -Per call shape: - -- A `where` naming a scalar `id` **and nothing else** is unchanged — by-id, with - or without `multi: true`. -- A `where` carrying a scalar `id` **plus other keys**, with a declared - `multi: true`, now routes to the **predicate path** (`driver.updateMany` / - `driver.deleteMany`), which compiles every `where` key. Previously this - dispatched by-id and dropped the extra keys. -- The same shape **without** `multi: true` — and any by-id call via a scalar - `data.id` beside extra `where` keys — now **throws**, naming the keys the by-id - path would have dropped. - -A second shape refuses under its own code (`2810695`): a by-id `update` whose -truthy scalar `options.where.id` names a **different** row than the truthy scalar -payload `data.id` now answers `UPDATE_ID_MISMATCH`, HTTP `400`, naming both ids — -including ids differing only in type (`42` beside `'42'`). Equal ids are -unchanged, which is the normal REST spelling. A declared `multi: true` does not -rescue the call. - -**Migration.** Each refusal is a one-line edit at the call site, and which edit is -an intent decision no codemod may make for you: - -| You wrote | Decide | -| :--- | :--- | -| `{ where: { id, …other } }` without `multi` | declare `multi: true` so the full `where` is honoured (the result becomes the matched count), or drop the extra `where` keys to keep an unconditional single-row write | -| `data.id` ≠ `where.id` | make the two ids equal (or drop `where.id`) to address the row by the payload id, or remove `id` from the payload to address it by `where.id` | - -⚠️ **Flow authors reach this through `update_record` / `delete_record` nodes** -whose `filter` names `id` plus other keys without declaring `multi: true`. Those -configs were silently unconditional before and refuse loudly now. - -#### `http_request_errors_total` is retired (#9834) - -⛔ **If you have a Grafana panel, an alert rule or a recording rule keyed on -`http_request_errors_total`, it will read a FLAT ZERO after this upgrade.** That -zero is the removal, not a healthy server, and it is the one way this change can -hurt you — nothing throws, nothing warns, the series simply stops receiving -samples (`914c413`). Its only emitter was the dispatcher's own route Proxy, so it -never saw the auth mount, the REST data API, or any other inbound surface: it -undercounted from day one. - -| Wrote | Write instead | -| :--- | :--- | -| `rate(http_request_errors_total[5m])` | `rate(http_requests_total{status=~"5.."}[5m])` — emitted by the transport, so it covers every inbound surface instead of the dispatcher's routes only | -| `sum by (route) (http_request_errors_total)` | `sum by (route) (http_requests_total{status=~"5.."})` | -| `SEMCONV.httpRequestErrorsTotal` / `RUNTIME_METRICS.httpRequestErrorsTotal` in host code | Delete the read. Both members are gone; `tsc` reports the missing property at the read site. | - -#### Four ADR-0049 enforce-or-remove retirements on the authorable surface - -Each of these was declared, projected and accepted while nothing read it. Because -the authorable surface has been strict since 17.0.0 (#4001), an authored document -that still carries one of these keys is now **refused by name at parse**, not -silently dropped. - -| Retired | Where it lived | Write instead | -| :--- | :--- | :--- | -| `sys_position.permissions` (`3ee8ddf`, #9885) | a "JSON-serialized array of permission strings" textarea on the platform position object | Delete the key. Capability reaches a position **only** through permission-set bindings (`sys_position_permission_set` rows); prose documenting intent belongs in `description`. | -| `MetricSchema.filters` (`a40dcc1`, #10414) | the per-metric raw-SQL filter | Delete the key. | -| `record:highlights` highlight-field `icon` (`c684d00`, #10054) | advertised on six surfaces, drawn by nothing | Delete the key. | -| the `themes` carrier key and `ThemeSchema` (`35ad101`, #10485) | the authoring surface nothing ever applied | `app.branding` remains the one color-authoring surface. | - -Physical columns on already-deployed databases are untouched — ADR-0045 schema -sync is additive. - -#### Analytics stops answering the wrong number on a cross-object filter - -A filter nested inside a combinator (`$or`, `$not`, a nested `$and`) on the -ObjectQL path used to reach `engine.aggregate` unchecked, because the -cross-object envelope check only saw a top-level AND-ed leaf. Both analytics doors -now refuse it the same way a top-level cross-object filter already was -(`57e4571`, #10759), and a dataset's own definition-level `filter` gets the -identical guard (`13a3dca`, #10861). - -#### Driver introspection stops guessing (#11161) - -`driver-sql`'s `introspectPrimaryKeys` / `introspectForeignKeys` / -`introspectUniqueConstraints` wrapped their whole dialect dispatch in a bare -`catch {}` and returned `[]`, so a query a live server rejected degraded to "this -table has no primary key" with no diagnostic — a wrong answer downstream code -acted on, not "we don't know". A failed read now **throws** by default; -`{ onFailure: 'partial' }` opts a caller with a self-correcting short read back -into the old behaviour (`9cc1940`). The un-hiding immediately paid: the Postgres -arm of `introspectUniqueConstraints` had been invalid SQL all along, so live -Postgres never reported a unique constraint through this method. That query is -repaired in the same change. - -#### The external-datasource federation family requires a capability - -These routes previously admitted **any authenticated caller**. This is published -SDK surface — `datasources.external.*` on `ObjectStackClient` and the CLI's -`datasource` commands reach exactly these routes — so an existing integration -presenting a valid credential that holds neither capability was served before and -is **refused now**, `403 PERMISSION_DENIED` naming the missing capability -(`9a1ed7a`, #9901; `6ce58a7`, #10255). - -| Route | SDK call | Now requires | -| :--- | :--- | :--- | -| `GET /:name/external/tables` | `datasources.external.listTables` | `manage_platform_settings` | -| `POST /:name/external/tables/:remote/draft` | `datasources.external.draft` | `manage_platform_settings` | -| `POST /:name/external/tables/:remote/import` | `datasources.external.import` | `manage_metadata` | -| `POST /:name/external/refresh-catalog` | `datasources.external.refreshCatalog` | `manage_metadata` | -| `POST /:name/external/validate` | `datasources.external.validate` | `manage_platform_settings` | - -**Migration.** Grant the calling credential's permission set the named capability. -The platform's `admin_full_access` set carries both; a purpose-built operator set -is the case to check. - -#### A per-item publish naming `?package=` stops matching another package's draft - -`POST /api/v1/meta/:type/:name/publish?package=PKG_ID` now resolves its draft's -org scope **package-exactly**, closing a path where the scope probe could match a -different package's draft in the caller's org and the package-exact promote then -`404`'d over the caller's own publishable draft (`c74aefe`). What you may newly -see: a publish that states `?package=` no longer discovers a draft of the same -`(type, name)` authored with **no** package binding — it answers `404 [no_draft]`. -**Remedy:** if the draft you mean is the package-less one, retry the publish -without the `?package=` query parameter. - -#### Two dead CLI authoring surfaces are gone - -- **`os g agent` is retired** (`15b63e8`, ADR-0063 §2, #10359). ⛔ If a script, a - Makefile or a CI step runs it, **it now exits 1** — that is the intended - outcome and the one way this change interrupts you. The kernel ships exactly - two agents, `ask` and `build`, bound by surface, and the runtime catalog filters - out every non-platform agent record: the scaffolded file parsed, validated, - published, and then never appeared anywhere. The refusal names skills as the - surface to author instead. -- **The `@capabilities` hook-body directive comment is retired** (`7940de5`, - #10917). **Nothing an author wrote has to change**: `loadConfig` runs every - config through `bundle-require` and esbuild, which strips `//` line comments - before the handler is ever a runtime function, so the directive reached the - extractor from none of the four ordinary authoring shapes. A config that still - carries the comment builds to the same artifact. Declare capabilities as data in - `body.capabilities` on the hook or action. - -#### Smaller breaking changes in 17.2.0 - -- The better-auth-native `/api/v1/auth/admin/` routes refuse an anonymous caller - with the declared ADR-0112 envelope instead of a `401` whose body was the empty - string under an `application/json` header (`4d7c564`, #10349). Statuses and - admission are unchanged; what is added is the machine-readable `code`. -- `IHttpOutbox.redeliver` and `MessagingService.redeliverHttp` change signature so - the caller's tenant is threaded rather than swept globally (`cdaa72f`, #10740). - `SqlHttpOutbox.redeliver` now rides the predicate path, so a delivery row - claimed between its read and its write is not reset and `redeliver` reports - `DELIVERY_NOT_ELIGIBLE` instead of success. -- Thirteen logger sink types now declare a **non-optional** `warn`, so a - durability report always has somewhere to land (`e222a53`, `b47ba2c`, #9754, - #10556). Compile-time only — `error` stays optional, call sites keep the - `logger?.warn?.(…)` backstop, and no runtime behaviour changes. It can break a - host that injects its own reduced sink. -- `driver-sql`'s `introspectSchema()` emits the spec introspection contract — - `primaryKey`, `dialect`, `introspectedAt` (`95437e7`, #10676, #10998). -- The `/meta` FSM state route is singular: `meta.getLegalNextStates` moves and the - plural registration is retired (`67630c4`, #10077). - -### New capabilities in 17.2.0 - -- **`lifecycle.ttl` accepts an `onlyWhen` row filter** mirroring - `retention.onlyWhen`, and the shared `onlyWhen` value union gains the platform's - canonical relative-date vocabulary (`8012960`). -- **`os g skill NAME` scaffolds an AI skill**, written as `NAME.skill.ts` so the - loader finds it (`1c3a46f`, #11025) — the replacement surface `os g agent` now - points at. -- **A per-column sortability projection** is served on - `GET /api/v1/meta/:type/:name` (`e5ea701`), so a console can disable the sort - affordance on a column the query engine will refuse rather than discovering it - at query time. -- **`sharingModel` on the solution blueprint's object schema** — the enum - `private | public_read | public_read_write | controlled_by_parent` reaches the - design stage (`7d2d112`), and `nameField` joins the strict blueprint mirror - (`ceb33a9`). -- **`theme` and `analytics_cube` are validated at the `/meta` write door** - (`2306a76`, #10194), so a Studio or API write is judged by the same rules as an - `os build`. -- **`os migrate duplicates` reports the rows blocking the three `kernel:ready` - uniqueness reconciliations** (`2866d5f`) instead of leaving a boot to fail on - them. -- **`MetadataRepository.watch()` replays from the durable log on a numeric - `since`** (`f334d66`), so a reconnecting watcher no longer silently starts from - now. -- The runtime publish gate reaches further: list-view field rules judge a - standalone `view` write, and a standalone `ViewItem` record's nested - `config.sort` / `config.searchableFields` are judged there too (`adbcbfd`, - `f1b5ad3`, `def0d3e`) — Studio and metadata-API writes are held to the rules - `os build` already applied. -- `os lint` reports an unparseable source instead of scoring it CLEAN - (`78818ec`, #10653); `os serve` refuses a relative `plugins: [...]` entry, - naming the two spellings that work (`e598b1c`). -- Time-relative sweeps are idempotent per matched window (`73d9795`, #10220); - `sys_session` gains a declared ADR-0057 lifecycle policy (`dccbcec`, #7826); the - `manager` approver is screened to the request's organization (`13f533a`, - #10153); and the `__search` companion is no longer provisioned on objects whose - only companion source is the primary key (`2570ab0`, #10290). - -### New in Console (Studio) — objectui pin `9a3daf8d37ad → 190fbd01d061` - -One pin move (`208bd22`), derived from 113 releasing changesets over 141 objectui -commits. What a console host or an author notices: - -- ⚠️ **Console hosts:** `options.actor` is removed from `MetadataClient`'s `save` - / `reset` / `publish` / `rollback`, and the `X-Actor` request header is no - longer emitted (objectui `8e00bfd28`). The six retired `@objectstack/spec/ui` - theme-schema re-exports leave `@object-ui/types/zod` with the `themes` - retirement above (objectui `920165d18`), and `ThemeComponentSchema` - (`type: 'theme'`) — a component kind no renderer implemented — is retired - (objectui `78cbdb530`), as is the register-meta key `defaultChildren` - (objectui `fa429cf6f`). -- **`stack` reads its spacing from `gap` and nothing else** — the undeclared - `spacing` key it also accepted is gone (objectui `dd194635d`). -- Authored predicates are actually consulted: `record:alert` binds its row through - `usePredicateRecordContext` so `properties.visible` is read (objectui - `8a4439081`), and a hoisted `properties.visible` stops swallowing a declared - `visibleWhen` (objectui `c86185eb5`). -- `object-grid` / `object-form` / `detail-view` resolve their data source the same - way, and a block that resolves none says so (objectui `ebce5a367`); `ReportView` - reads `dataSource.object`, the one key the contract declares (objectui - `60d452ee0`); `data-table` reads the declared `header` (objectui `e719ebdd9`). -- Create forms pre-fill the `current_user` `defaultValue` token with the acting - user (objectui `3c9fca3dc`); three more secret-field spellings stop rendering a - secret in clear text on the unregistered-widget branch (objectui `91783c47b`); - and a screen flow's resume result reaches the user on both outcomes (objectui - `c40f3b8ca`). - -The per-commit list is in `packages/console/CHANGELOG.md` under `## 17.2.0`, -which records the upstream objectui commit for every entry. - ---- - -## What's new in 17.3.0 - -17.3.0 was published to the `latest` tag on **2026-09-04**, twelve days after -17.2.0. The version-locked train moved the same **69 packages**, carrying **862 -distinct changelog entries and no major** — **100** of which mark themselves -BREAKING. (Counted across the 69 package `CHANGELOG.md` files; an entry that -lands in several packages is counted once.) It is by a wide margin the largest -release of the v17 line. The bundled Console advances four pins, -`190fbd01d061 → 9602dc820450 → d8ec8d6d4f01 → 67dadd602a3a → 00d3f09c500c`. - -⚠️ **Read this before treating the version number as a safety guarantee.** As with -17.1.0 and 17.2.0, entries that landed after the 17.0.0 cut ship as `minor` under -the lockstep launch-window convention while being explicitly breaking. Three -things in this release change behaviour on a **running** deployment with nothing -to parse-fail on — the audience-posture default, the tenant-scoping of -`sys_record_share`, and the newly-enforced `driver-memory` uniqueness — and one -renames a published SDK namespace with **no aliases at all**. - -### Breaking changes & migration in 17.3.0 - -**This section is triaged, not exhaustive.** An entry is written up here when the -change can be reached from something an application ships or operates — its -metadata, its data, its own code calling the SDK / REST / CLI, its deployment -config, or a plugin it authors. On that rule 94 of the release's 100 self-marked -BREAKING entries are named below, by changeset hash, and six are left to the -per-package `CHANGELOG.md` files because nothing an application can author or -call reaches them: the six branded identifier schemas and `EventNameSchema` -(`45b9051`), `MetadataChangedEventPayloadSchema` — a payload nothing ever emitted -or consumed (`50d6c92`), `RestApiEndpoint.handlerStatus` with the Route Coverage -Report shapes (`53d3689`), the orphan `CLICommandContributionSchema` export -(`7a25e7d`), `SendTemplateInput.org` (`8619f95`), and `FilesystemLoader.list()` -reporting only the names its siblings can resolve (`4b4d5a3`). The four Console -pin refreshes are named once under [New in -Console](#new-in-console-studio--objectui-pins-in-1730) rather than enumerated -here. - -#### `client.projects.*` becomes `client.environments.*` (#12866, #12882, ADR-0006 D2) - -**No aliases exist.** The old namespace is gone, not deprecated — there is no -`client.projects` getter, no `res.project ?? res.environment` hedge, and none is -coming (ADR-0006 D3 declined a mapping layer with reasons; the v5.0 rename rule -「no aliases」 is the standing one). Every call site moves in one edit -(`87042b5`). This is the **SDK half** of one coordinated cross-repo rename; the -producer half is the cloud control plane, renaming the same field keys on the same -endpoints. - -**Method namespace.** Every member moves, `list` / `get` / `create` / `update` / -`delete` / `activate` / `rotateCredential` / `updateHostname` / `updateVisibility` -/ `listRevisions` / `listBranches` / `renameBranch` / `deleteBranch` / -`retryProvisioning` / `listDrivers`, and the nested `client.projects.packages.*` -with them: - -```ts -client.projects.list(…) // before -client.environments.list(…) // now -``` - -**The environment-scoped sub-client** moves in the same edit — `client.project(id)` -→ `client.environment(id)`, and the exported class `ScopedProjectClient` → -`ScopedEnvironmentClient`. An `import { ScopedProjectClient }` fails at the import -line, which is the loudest and most precise channel this change has. Nothing about -its behaviour moves. - -**Response keys.** - -| before | after | where | -| :--- | :--- | :--- | -| `res.projects` | `res.environments` | `list` (the `total` key is unchanged) | -| `res.project` | `res.environment` | `get`, `update`, `activate`, `updateHostname`, `updateVisibility`, `retryProvisioning` | - -The joined blocks on `get` (`database`, `credential`, `membership`, -`organization`) keep their names, as do every `packages.*` key, the `delete` / -`listBranches` / `renameBranch` / `deleteBranch` payloads and `listRevisions`. -The **URL paths are unchanged** — they were already on the `environments` -spelling. - -**CLI.** `os environments list | show | create | switch | bind` follow the same -rename. No flag, argument, exit code or command id changes, but `--format json` / -`--format yaml` payloads are the control-plane response verbatim, so **a script -reading `.projects` or `.project` from those payloads reads `.environments` / -`.environment` instead.** - -Two declarations that were *false* before this change are corrected rather than -carried forward: `create` never answered a `project` key at all (it has always -answered `{ environment, warnings, durationMs, hostnameAssignment? }`), and it -declares no `database` key. `os environments create --activate` silently never -activated because it read `res.project.id` through the wrong declaration; both are -fixed here. - -⛔ Deliberately **not** renamed, each needing its own decision: `setProjectId` / -`getProjectId` on the client (a cross-package protocol contract), and the REST API -config keys `enableProjectScoping` / `projectResolution`. - -#### One declared audience posture, defaulting to `invite_only` (`4f24e9d`) - -"Who may become a user of an environment's apps" is now **one** declaration -instead of an emergent property of five switches — and its default flips to the -safe end. - -- **FROM:** an undeclared audience meant open email/password self-registration - with no email verification, and self-registrants implicitly fell back to the - `member_default` permission set. -- **TO:** an undeclared audience **is** `invite_only`. Self-serve sign-up — - email/password, social-provider OAuth JIT, magic-link/OTP/phone/anonymous, and - any unclassified creation method — is refused `403 SELF_REGISTRATION_CLOSED` - unless the address holds a pending `sys_invitation`. The first account on a - fresh install is exempt (the bootstrap bypass). - -⚠️ **Nothing parse-fails.** A deployment that meant to stay open upgrades into a -closed door. **One-line fix:** - -```ts -auth: { audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' } } -``` - -The new `auth.audience` surface on `AuthConfig` declares `posture` -(`invite_only | email_domain | open`), `allowedEmailDomains` (required non-empty -for `email_domain`) and `selfRegistrationPermissionSet` (required whenever the -posture permits self-registration; `admin_full_access` is refused). Off-vocabulary -postures and inert declarations are refused at parse **and** at plugin-auth's -config entry — never coerced. `email_domain` admits only allowlisted domains -(`403 EMAIL_DOMAIN_NOT_ALLOWED`; exact case-insensitive match, subdomains not -implied). Any self-registration-permitting posture **forces** -`requireEmailVerification` on, and an explicit `false` beside it is refused at -boot. - -⛔ **Operator-driven creation is never posture-gated:** admin create-user, bulk -import, SCIM provisioning and JIT through operator-registered identity providers -keep working under every posture. - -#### A permission-store read failure fails LOUD (#13279) - -`resolveAuthzContext`'s per-read helper answered a **thrown** read exactly the way -it answered an **empty** one, so an outage of the permission store resolved as a -well-formed context for an authenticated principal holding no capabilities — and -the door answered `403 FORBIDDEN`, measured byte-identical to what a caller who -genuinely holds nothing receives. An administrator was told they lack a -capability, during an outage of the store that holds the capability (`6a180e4`). - -An **unreachable** store now raises `AuthzStoreUnavailableError`, carrying the -existing ADR-0112 code `SERVICE_UNAVAILABLE` and status `503`. No code is added to -the closed wire vocabulary. What did **not** change, and is pinned: a reachable -but genuinely empty store still resolves to zero capabilities; a genuine denial -still answers `403 FORBIDDEN`; a real engine whose `sys_*` tables were never -provisioned still resolves to zero capabilities, quietly; anonymous requests never -reach the store. - -**Migration.** This is all-transport, not just REST. A caller that treats *any* -throw from `resolveAuthzContext` as "anonymous" should re-raise -`isAuthzStoreUnavailableError(err)` instead — degrading it restores the disguise -this removes. `isMissingTableError` moved to `@objectstack/types`; -`@objectstack/metadata/errors` still re-exports it, so no consumer of that -published subpath changes. - -#### `driver-memory` enforces the uniqueness it always declared (#13197, #13239) - -`InMemoryDriver` enforced **no uniqueness at all**: `create` was a `table.push()`, -so a `unique: true` field (`56c093c`) and a `unique` object-level `indexes[]` -entry (`b7f645a`) were declared-and-not-enforced — the colliding write landed and -a read returned both rows. Both surfaces now refuse with the SQL family's -envelope: `code: 'UNIQUE_VIOLATION'`, `status: 409`. The scoping is `driver-sql`'s, -reproduced arm for arm rather than reinvented. - -⚠️ **This is the entry most likely to present as "our seed data stopped -loading".** 57 in-repo production and metadata declaration sites carry a `unique` -`indexes[]` entry — `sys_user`, `sys_session`, `sys_setting`, `sys_metadata`, -`sys_member`, `sys_team_member` and most of the identity surface among them — so -any stack served by `InMemoryDriver` newly enforces constraints the SQL family -already enforced. Every one of those refusals is a write SQL would have refused -too, and existing rows are never retroactively refused. **Migration:** a fixture -that relied on duplicates landing on a declared-unique field must stop declaring -`unique`, or stop writing the duplicate. - -⚠️ **Bare `true` means the opposite on the two surfaces, and this reproduces the -disagreement rather than smoothing it.** At **field** level `unique: true` is the -positional spelling of `'organization'`; on a **declared index** it is the -positional spelling of `'global'` — the listed columns verbatim, no organization -key part. That is deliberate, and it is staged for retirement at protocol 18. - -#### `sys_record_share` is tenant-scoped, and pre-existing rows need a backfill (#14484) - -Every `sys_record_share` row on every deployment was written with -`organization_id = NULL`: `SharingService.grant` wrote under a bare system context -and the row literal never carried the column. Reads agreed with writes, so nothing -was visibly broken; what the NULL cost was the cliff — the first tenant-facing read -of the table inherits plugin-security's Layer 0, whose strict -`organization_id = :tenant` wins, and **every existing grant silently disappears** -(not refused; simply "this person was never granted access") (`3f64fe6`). - -**Writer.** `SharingService.grant` now stamps `organization_id` on both halves of -its upsert. A rule-materialised grant carries the granting rule's organization; a -direct grant carries the shared record's organization, with the acting session's -organization as the fallback only for a record that carries none. - -**Ops — the backfill, dry run first and by default.** An operator-invoked module -scans only rows whose organization column is unset, re-reads each row's record at -repair time, and stamps the row with the record's own organization: - -- `planSysRecordShareOrganizationBackfill(engine)` reads only and returns a report - naming every row it would touch. -- `runSysRecordShareOrganizationBackfill(engine, { dryRun: false })` writes. - -Nothing runs at boot and nothing is scheduled. It is idempotent by construction. -Orphan rows — grants whose record no longer exists — are left NULL, counted and -logged, never deleted here: that population is already owned by the -`kernel:bootstrapped` orphan sweep. - -⚠️ **On a walled install, two shipped paths that can resolve no organization now -meet a loud refusal** (`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`, status 500) where -they previously wrote a NULL row: a platform-global sharing rule matching an -organization-less record, and a direct grant whose read of the shared record's -organization failed. On a `single` install with exactly one organization both -derive it. - -#### `POST /api/v1/automation/:name/toggle` requires `manage_metadata` (`266436a`) - -| | before | after | -| :--- | :--- | :--- | -| authenticated caller **with** `manage_metadata` | 200, flow toggled | 200 — unchanged | -| authenticated caller **without** it | 200, flow toggled | **403 `PERMISSION_DENIED`** | -| anonymous caller | 401 | 401 — unchanged | -| engine self-invocation (`isSystem`) | 200 | 200 — unchanged | - -Nothing else on the domain moves: `POST /:name/trigger`, the legacy -`POST /trigger/:name` and `POST /:name/runs/:runId/resume` are untouched, so -ordinary members can still run the flows built for them, and -`GET /automation/_status` still serves enablement to any authenticated caller. - -The measurement behind it: the enabled bit is not a row, so no organization wall -scopes it. A tenant org owner without the capability — refused `403` by -`PUT /meta/:type/:name` at the same session — switched a shipped flow off, and an -unrelated tenant in a **different** organization read it off. Disabling a shipped -flow is functionally equivalent to deleting it for as long as it stays off, and -`DELETE /:name` was already gated. - -**Migration.** A caller that toggles flows programmatically — -`client.automation.toggle(name, enabled)` — must present a principal holding -`manage_metadata`. - -#### Four SDK methods stop handing you the dispatcher envelope (#13079) - -`analytics.query` / `analytics.meta` / `analytics.explain` and -`automation.trigger` ended `return res.json()`, so their callers alone had to read -`.data`. All four now resolve to the payload, as every other dispatcher-served -method already did (`db16b94`). - -| method | resolves to (now) | rewrite | -| :--- | :--- | :--- | -| `client.analytics.query(q)` | `AnalyticsResult` | `r.data.rows` → `r.rows` | -| `client.analytics.meta(cube?)` | the bare cube list | `r.data[0].name` → `r[0].name` | -| `client.analytics.explain(q)` | `{ sql, params }` | `r.data.sql` → `r.sql` | -| `client.automation.trigger(name, payload)` | `AutomationResult` | `r.data.status` → `r.status` | - -For the three analytics methods every old read is a compile error (TS2339), so a -TypeScript consumer finds each site at build time. ⚠️ **`automation.trigger` is -the exception the compiler will not point at:** `r.success` and `r.error` compile -before *and* after, because `AutomationResult` declares them itself — but their -**meaning** moves, from the envelope's always-`true` flag to the run's own. A -consumer branching on either must re-read that branch by hand. A JavaScript -consumer reads `undefined` from `.data`. - -⛔ **Your `catch` blocks are unchanged.** Non-2xx answers threw before and throw -now, carrying the ADR-0112 envelope; this convergence is not "errors now throw". -`client.analytics.queryDataset(...)` is not converted — it never had an envelope. - -#### Compound metadata addressing is retired (`7986d97`) - -Stage 3 of the retirement of slash-bearing metadata item names. Stage 1 declared -the item-name grammar and refuses every slash-bearing name at the publish door -(`311433f`, #12194), so the routes removed here addressed only names that can no -longer be created. - -| stops answering | use instead | -| :--- | :--- | -| `GET /api/v1/meta/:type/:section/:name` | `GET /api/v1/meta/:type/:name` | -| `PUT /api/v1/meta/:type/:section/:name` | `PUT /api/v1/meta/:type/:name` | -| `GET /api/v1/meta/:type/:section/:name/published` | `GET /api/v1/meta/:type/:name/published` | - -A request to a retired path now answers `404 ROUTE_NOT_FOUND`. Address every item -through the single-segment route and percent-encode the name: - -``` -GET /api/v1/meta/lead/views/all_leads → GET /api/v1/meta/lead/views%2Fall_leads -``` - -**`@objectstack/client` callers need no change** — the SDK already sends the new -spelling, and the encoding is a no-op for every name the item-name grammar admits, -so the bytes on the wire are unchanged for every name that can be written today. A -pre-grammar residue row whose stored name contains a slash stays readable, -writable and deletable. - -#### The `RestServerConfig` blocks are parsed, not ignored - -`RestServer` now parses `config.api` (`1394768`), `api.projectResolution` -(`8965398`) and `config.crud` (`f60ab90`) by name, and the ten inert keys the -liveness ledger recorded as `dead` are removed (`b3a63d3`, #14691). A key that -used to be accepted and silently dropped — leaving the server on its defaults — is -now refused by name. - -#### Author-time refusals that can fail a stack which built clean on 17.2.0 - -Re-run `os validate` / `os build` after upgrading. Each of these is a parse-time -refusal naming the key, not a silent drop: - -- **Plugin manifests go strict.** `manifest:` refuses unknown keys, with its - nested `contributes` / `kinds[]` / `engine` / `engines` blocks (`4d0d944`), and - the `contributes` block loses its last ten dead members — `events`, `menus`, - `themes`, `translations`, `actions`, `drivers`, `fieldTypes`, `functions`, - `commands` (`be21955`, #10724) and `routes` (`bc56e18`, #10726) — as do the - three dead top-level containers `capabilities`, `configuration`, `extensions` - (`dce5cd4`, #11332). -- **`page.components[].responsive` and the `ResponsiveConfig` layout vocabulary - are retired** (`2a6122b`, #11027). -- **The `allowRestore` / `allowPurge` object-permission bits are retired** — - declared gates on operations that do not exist (`8af88dd`, #12497). -- **`lookup` / `master_detail` fields require a non-empty `reference`** - (`0fb8760`, #13632). -- **An `autonumber` field is `unique: 'organization'` by default**; explicit - `unique: false` opts out (`f8e8f03`, #13894). Read this beside the - `driver-memory` enforcement above — the two compose. -- **`null` is refused in comparand positions**: `$in` / `$nin` members and - `$between` bounds (`e398863`), and the ordering comparands `$gt` / `$gte` / - `$lt` / `$lte` (`d16df74`). -- **`address` and `location` values refuse undeclared keys** (`d62f990`, #13802). -- **An authored `radio` with `multiple: true` is refused at the schema layer** - (`348860c`, #11437). -- **Calendar in `appearance.allowedVisualizations` requires - `calendar.startDateField`** on list views (`96e25a8`, #13817). -- **`defineStack` refuses two actions resolving to one scope-qualified runtime - key**, and `composeStacks` refuses two input stacks that do (`279431e`, - `35dffea`); `defineStack` also refuses a stack declaring an auto-launched flow - while `requires` omits `'triggers'` (`948dd6b`, #14153). -- **A `type: 'script'` action may not declare both post-success navigation - channels** — `onSuccess` beside `opensInNewTab: true` is refused, and - `newTabUrl` without `opensInNewTab` is refused (`387e231`, `2d4fa75`). Pick one - destination: keep `onSuccess`, or keep `opensInNewTab` plus the handler's - returned `redirectUrl`. -- **`ComponentPropsMap` convergences:** `element:number`'s `filter` onto the - `ViewFilterRule` array form (`79b6a22`), `object-grid`'s `data` onto - `ViewDataSchema` (`8f79379`), `object-grid`'s legacy `defaultSort` fallback - retired (`e6ca40e`), the per-option `default` key narrowed out of the form-view - options vocabulary (`c459da6`), and `user:profile` refused by name as not - author-placeable (`97a2263`). -- **The `sys_scim_provider` platform object is retired** (`4d25d22`, #11757), and - with it the `AUTH_SCIM_PROVIDER_SCHEMA` / `AUTH_SSO_PROVIDER_SCHEMA` public - exports (`911da5f`, `89448a5`). -- **Further ADR-0049 retirements of authorable surfaces**, each a refusal naming - the key with "delete the key" as the whole migration: the `element:form` - element at element grain (`7345308`, #9249), the import-mapping `lookup` - transform's steering params (`15d58db`, #10329), the authorable - `AdvancedPluginLifecycleConfig` surface (`40a93b5`, #11825), - `HotReloadConfig.distributedConfig` and `watchPatterns` together with the two - `stateStrategy` values that were never implemented (`4635f3e`, #12340; - `ee3595c`, #12428), the inert `PluginMetadata` surfaces `configSchema` and - `hotReloadable` (`49f0dcf`, #11982, #12587), the three `PluginHealthCheck` - restart keys the monitor never performed (`b72db01`, #12032), the - component-translation `submitLabel` copy key (`d173125`, #10926), and the paper - metadata-customization protocol with its full coupling set (`9e0ba21`, #13135). - -#### Runtime and driver behaviour changes worth checking - -- **A `multi: true` update whose per-row `beforeUpdate` hooks write divergent key - sets is refused** (`dee4dd4`, #14099), and **an undeclared field a `before*` hook - writes is refused identically on every driver** (`b003cf2`). -- **A third `UPDATE_ID_MISMATCH` shape refuses** — a bound truthy scalar payload - `data.id` beside a *declared but non-scalar* `options.where.id` (`{ $in: [...] }`, - an array, `null`) (`5d16379`, #11230). This is the shape 17.2.0's #11142 - deliberately left standing. -- **`update()` on a missing id answers `null` on MongoDB and on Turso's remote - face** (`ca3fd4b`), and `driver-memory`'s `update()` / `upsert()` publish their - honest types (`93940d4`). -- **`redshift` / `cockroachdb` DDL is refused by name** and `pgnative` joins the - Postgres family (`dfebfc8`, #11991); the dangling `postgres` and `nats` values - leave `ClusterDriverSchema` (`c85a265`, #13393). -- **`sys_oauth_resource.identifier` narrows 1024 → 255**, and the referring column - with it (`d79c602`, #12313). -- **Walled postures elevate only the env-declared platform owner, never the first - registrant** (`9735662`, #11184), and the metadata HMR door is gated on an - explicit development posture (`f4e7ae5`, #12140). -- **`publicSharing.eligibility` and `publicSharing.enabled` are held at - redemption, not only at mint** (`fc9ba76`, `13bf05d`, `20293d6`) — a share link - minted while sharing was enabled stops redeeming once it is disabled. -- **`escalation.enabled` defaults to `true` and the SLA sweep finally reads it** - (`277948f`, #12278) — an approval process that declared escalation and saw none - now escalates. -- **`controlled_by_parent` composes across a chain**: a child whose master is - itself derived is no longer readable and writable org-wide (`6171331`, #11082). -- **`ExecutionStepLog.iteration` is single-valued** — the enclosing loop iteration - — and the parallel branch index moves to a new optional `branch` key (`8ab926b`, - #14414). -- **`os serve` defaults `NODE_ENV` to `production` when unset**, exactly as - `os start` already did (`918988a`, #11113); `os validate --json --strict` exits - 1 on the configs `--strict` already exits 1 for (`ab23c67`, #11174); and the - multi-node cluster gate fails **closed** when unregistered, mounted on every - boot route (`4d672c4`, #13537). - -#### Smaller breaking changes in 17.3.0 - -- **Four SDK response contracts are declared where they were unbound or wrong.** - The 17 previously-unbound client-SDK methods get their published response - contracts, and the false `PackageRollbackResponseSchema` is retired - (`dc75ba8`, #12038); `meta.deleteItem` declares the response the reset door - actually sends (`426ad58`, #13023); `SaveReportInput`'s requirements are stated - at the `reports.save` door (`3519f8d`, #11926). A TypeScript caller reading a - member the route never sent now sees it at build time. -- **A published package must declare an `exports` map**, and that is now a gate - (`6a571d3`, #12879). -- **`Plugin.type` is the closed set the spec declares** — a `PluginType` derived - from `CORE_PLUGIN_TYPES` (`d8024f0`, #13925), so a plugin declaring an - off-vocabulary type is refused rather than silently unrouted. -- **`getUiView`'s list branch honours `hidden` on the priority pass**, not just - the fill pass (`2a75270`, #13259) — a field marked hidden stops appearing in a - served list view's leading columns. -- **A quoted-empty `If-Match` entity-tag is refused** instead of silently - disabling optimistic concurrency (`47389b3`, #13576). -- **The flat-input proxy refuses a symbol key** at `set` / `defineProperty` - instead of silently persisting it (`c34f693`, #12603). -- **A deleted record's pending approvals auto-cancel** instead of stranding in the - inbox (`dda969c`, #13568), and a contained per-iteration flow failure becomes - visible in the run contract — run-level `failed`, loop iteration through `try` / - `catch`, row identity on `$error` (`18d816a`, #13681). -- **Wizard view v1** tightens `FormViewSchema` `type: 'wizard'` into a - declaration-and-refusal shape (`4bc18e5`, #13704); `cloud-connection:panel`, - `marketplace:installed-list` (`772d5de`) and `mcp:connect-agent` (`ce80ec2`) - are declared in `ComponentPropsMap`, so undeclared keys on those three widgets - are refused; and `KnowledgeRefreshPolicy.cron` is typed with the shared cron - dialect (`778c59f`, #14825). -- **Deleting a datasource evicts its driver from the data-engine registry**, so - `/api/v1/ready` recovers without a process restart (`ba64877`); a nested - datasource-config credential position is treated identically to the top-level - key it mirrors (`51ecb2f`, #13405). -- **Driver-level storage corrections that change stored or returned bytes:** the - SQLite `Field.json` codec becomes injective, one encoding across all three - dialects (`4045b95`, #12380), and MySQL stamps `updated_at` at the audit - column's own precision so an updated row stops reading as modified *before* it - was created (`64505a5`, #11224). -- **Interfaces a custom driver or plugin implements move:** `IDataDriver.update()` - declares its not-found arm (`93940d4`, #13878), `DatasourceDriverHandle.introspectSchema` - declares the spec introspection contract (`3d79144`), - `StrategyContext.executeAggregate`'s `aggregations[].method` narrows from - `string` to `AggregationFunction` (`d028b37`, #12776), `INotificationOutbox.ack()` - takes back the claimed record instead of a bare row id (`d9cf78e`), and - `sweepOrphanedRowsByRecordExistence`'s published parameter is tightened - (`3194c91`). - -### New capabilities in 17.3.0 - -- **A project of N packages compiles into one `packages[]` artifact**, with the - assembled package body declared (`7085f90`, #14439; ADR-0130). `composeStacks` - gains `manifest: 'preserve'` so N package identities survive composition - (`2e3e8c7`), and `EnvironmentArtifactSchema` declares `grantedPermissions` — the - install-time granted permission set per plugin (`e58ea8b`). -- **A row action gets the declarative single-record field write** — - `operation: 'update'` plus `patch`, with no handler to write (`effae80`, - #14092). -- **A layout section can reference a declared field group** instead of copying its - members (`39404f3`, #13855), and `fieldGroups[].visibleWhen` returns — this time - with its enforcement (`53dc739`, #12715). -- **An already-published page can be mounted on an object view** — a `page` member - on the `view` type enum (`d23dc08`, #13216) — and the command palette indexes - published pages through a page hit kind on `GET /api/v1/search` (`e764507`). -- **A sharing rule can share each matched record with the user or users named by a - field on that record** — `ShareRecipientType` gains `field` (`0f94cc7`). -- **`sys_user.locale` is a first-class column** carrying the user's own - notification language (`1401ae7`, #13881), a user may set their own - (`2fd3f1c`, #14787), and auth mail follows the caller's `Accept-Language` with - the deployment default second (`4bb412b`, #14319). -- **A rank-and-file member may edit their own `sys_user` row** (`ebb0822`, - #14959), and the verified platform owner bypasses the Layer 0 org wall - (`db39dfc`, #12974). -- **An org-scoped presentation-authoring capability, `manage_org_presentation`** - (`15eb2c9`, #12702). -- **The authorization-cache invalidation substrate** — an engine-seam write epoch - and the `authz.invalidated` channel (`4bd6faa`) — plus a cross-request - authorization grants cache (`86cbe37`, #11971) and a cached, synchronously - invalidated `sys_setting` localization read (`a8c00e2`, #11966). -- **Runtime metadata mutations and datasource record writes fan out to peer - replicas** (`1403d94`, `ef8a4b9`), so a runtime-authored object no longer answers - from one node only and a deleted datasource stops draining on the others. -- **`os lint` refuses a hook body that silently stopped being metadata** - (`ada3834`, #13651) and refuses a visibility predicate calling a CEL function the - environment does not register (`038f333`, #13594); list-view field references - (`aca23ab`) and an interface page's whitelisted visualizations (`e38da2b`) are - resolved at validate/build. -- **`--format json` failure envelopes carry the ADR-0112 `code` and `httpStatus`** - (`098a08f`, #13347), and `os validate --json` / `os build --json` carry the - computed advisory lists and `conversions` on **every** failure exit, not the - success payload alone (`33e81a5`, `d114d5e`, `79cf692`). `os lint` surfaces - ADR-0087 conversion notices (`9fd45a9`, #12297). -- **`os migrate plan` reports the platform-namespaced tables no declaration - accounts for** (`3f7f8f5`, #13204), and `os migrate apply` refuses **before** - writing any DDL when the host config exists but could not be loaded (`b4f2cda`, - #13118). -- **`meta.saveItem` can send the `If-Match` OCC header it already told callers to - send** (`6274a1a`, #11713), and `meta.deleteItem` — with `os meta delete` — can - pin a reset and discard only the pending draft (`cf71d73`, `2331b1e`). -- **`AutomationResult.status` names the terminally-failed-but-repairable run, - `'stranded'`** (`bd4aa4e`, #14384), `AutomationContext.recordLoadDenied` gives - the flow the caller-scope record-load signal (`63cd487`, #14244), and - `FLOW_INPUT_SCHEMA_INVALID` becomes a registered never-dispatched exit - (`f90e820`). -- **`DataEvent` and `BulkDataEvent` name the organization** the record or the - affected records belong to (`2aa8456`, `97bcd99`), so a tenant-scoped consumer - can tell whose event it is. -- **`Field.valueDomain`** — one closed standard-domain vocabulary shared by - settings specifiers and object fields (`1d7e76a`) — and `editMode?: 'modal' | - 'page'` on the object document (`f11fc61`, #11408). -- **The SQL connection pool is sized from `OS_DATABASE_POOL_MAX`** (`c4e8bbc`); - SQLite-family JSON columns declare `TEXT` while server dialects keep native JSON - (`9f4a6d5`, #12738); and `driver-mongodb` indexes `lookup` joins off the - canonical `reference` key (`eaba72e`, #13222). -- **`@better-auth/scim` moves from `1.7.0-rc.1` to stable `1.7.1`** — the - whole-model SCIM migration (`366f895`, epic #11632). - -### New in Console (Studio) — objectui pins in 17.3.0 - -Four pin moves (`df59de0`, `83be460`, `7642aac`, `41b5a44`) carrying the console -half of this release. The per-commit lists are in `packages/console/CHANGELOG.md` -under `## 17.3.0`, which records the upstream objectui commit for every entry — -including the entries objectui shipped with no changeset of their own, listed by -subject there rather than counted. - -⚠️ **Console hosts and authors:** several published `@object-ui/types` surfaces -are retired in this range — `MobileComponentConfig` (objectui `90665e07a`), -`DetailViewSection.hideEmpty` (objectui `1f31d3af6`), `ObjectTrigger` and -`ObjectRelationship` (objectui `41df89320`), and the `ActionCondition` -`{ expression, then, else }` branch shape on `ActionSchema.condition` (objectui -`6a9158602`). Relationship-target readers resolve a lookup's target from -`reference` alone, dropping the `reference_to` fallback arm (objectui -`045d20ba8`). - ---- - -## Upgrade checklist - -One checklist per release, for the release you are landing on **and** every -release you cross to get there. - - -**How far each list below has actually been walked.** The 17.3.0 list is the only -one on this page written against a **measured upgrade**: an application -repository was moved 17.2.0 → 17.3.0 by a reader holding nothing but the -published documentation, the `CHANGELOG.md` files inside the published npm -tarballs, and the `os` CLI's own output. Every line it produced is marked -**measured**, and several of them are changes whose own changelog entries are not -marked breaking — which is exactly why a checklist is not a restatement of -[Breaking changes & migration in 17.3.0](#breaking-changes--migration-in-1730). - -⛔ **That run covered one hop, 17.2.0 → 17.3.0. Nobody has walked 17.1.0 → -17.2.0.** Every line in the 17.2.0 list is derived from a change's own -**Migration** note and is marked **not exercised**: accurate about what changed, -unproven about what it costs to cross. The two are kept apart on purpose — a step -nobody has run, presented beside steps that were, is how a reader finishes a -checklist and believes they are done. - - -### 17.0.0 - -- **Node:** move to Node 22+ (and pin CI to 22). -- **Export:** add `allowExport: true` to every environment-authored permission - set whose holders must keep exporting — package-shipped sets are re-seeded for - you, and `member_default` deliberately does not carry the grant. -- **`apiMethods`:** run `node scripts/codemod/apimethods-legacy-to-primitives.mjs`, - replace legacy values with their primitives, and delete the key entirely if all - six remain. Watch for a legacy-only whitelist stripping to deny-all. -- **Metadata renames:** run `os migrate meta --from ` — it - applies `execute`→`target`, `conditionalRequired`→`requiredWhen`, - the retired-key deletions (`os migrate meta` handles all of them), sharing `full`→`edit`, and every - earlier step you skipped, in one pass. -- **Flows:** declare `runAs: 'system'` on any flow that reacts to system writes - and must act beyond one user's grants; otherwise ensure the trigger supplies a - user. -- **Agents:** move anything declared in `agent.tools[]` onto `skills`; drop - `stack.agents` (the runtime has never loaded them). -- **Auth:** plan for the better-auth 1.7 account-identity backfill — check the - boot log for federated accounts whose IdP is no longer registered, and stamp or - remove those rows. -- **Actions:** check any programmatic caller that posts `params` — a bag the - server used to accept silently now 400s if it misses a `required` param, - breaks `options`/`multiple`/`reference`, or carries an undeclared key. The - error names the param and the declared list. `OS_ALLOW_LAX_ACTION_PARAMS=1` - buys time for an integration you cannot reach today. -- **Files:** run `os migrate files-to-references` (dry run first, then - `--apply`) and reconcile — passing its self-check is what turns on strict - media value shapes *and* released-file collection for this deployment, so - read the report before you `--apply`. Do **not** reach for - `OS_DATA_VALUE_SHAPE_STRICT_ENABLED` to get there: it opts every value class - in at once, regardless of which migrations this deployment has actually run. -- **Reference and structured-JSON values:** run `os migrate value-shapes` and - fix what it reports before `--apply`. It converts nothing — the values it - names are application data — and a scan that was truncated or could not read - an object fails the gate even at zero violations. -- **Stored metadata (optional):** run `os migrate meta --stored` to see which - `sys_metadata` rows still carry a pre-17 shape, and `--apply` to rewrite - them. Unlike the two above this opens no gate and nothing depends on it — - those rows already read canonically, forever. It stops them re-converting on - every load, keeps diffs and exports clean going forward, and gives you an - exit code to assert on: nothing left to do exits `0`. -- **Datasources:** verify every declared datasource connects in every - environment — a bound datasource that cannot connect now fails the boot - instead of failing every later query. Delete `readReplicas` (`os migrate meta` - does it). Nothing ever opened those connections, so read throughput is - unchanged by removing them; if you need replica reads, front them behind one - endpoint (pgpool, ProxySQL, an RDS reader endpoint) and point `config` there. - Also re-check what you wrote under `config`: it is parsed against the driver's - contract now, so a key that used to be ignored — and left the datasource on - driver defaults — is rejected by name. -- **Sharing rules:** rewrite `sharedWith.type: 'group'` → `'team'`; drop `guest` - and owner-type rules; expect `accessLevel: 'full'` to convert to `'edit'`. - **A rule must state its criteria** — authoring one without is rejected, and a - stored criteria-less rule stops granting (its materialised grants are revoked - on the next reconcile). State the predicate, or use the object's - `sharingModel` if everyone should read it. -- **RLS policies:** delete `priority` (`os migrate meta` does it; outcomes are - unchanged). Re-check any policy you set `enabled: false` on — disabling now - actually withdraws its grant, which until v17 it silently did not. -- **Tools:** delete `category` / `permissions` / `active` / `builtIn` from tool - metadata (`os migrate meta` does it; none ever had an effect). To gate a tool, - gate the underlying action (`action.requiredPermissions`); to withdraw one, - remove it from the skills/agents that reference it. -- **Close-out sweep:** run `os migrate meta` once more — it also strips - `action.shortcut`/`bulkEnabled`, `flow.active`/`template`/node - `outputSchema`/`errorHandling.fallbackNodeId`, the inert view keys, - dashboard/widget `aria`/`performance`, `agent.knowledge` and - `skill.triggerPhrases`. If you set `flow.active: false` expecting a flow to - stop, set `status: 'obsolete'` — the flag never worked. -- **Anonymous access:** delete `api.requireAuth` (`os migrate meta` does it). If - you relied on `requireAuth: false` to serve something publicly, re-declare it - narrowly — a public form view, a share link, or `book.audience: 'public'` — - and make sure the stack mounts auth at all, or it now fails at boot. -- **Wait nodes:** `os migrate meta` moves `waitEventConfig.timeoutMs` onto - `timerDuration` and drops `onTimeout`. Anything that expected a wait to end on - a deadline never did — build the deadline explicitly if you need one. -- **Query callers:** these are request shapes, so the chain cannot fix them for - you. Drop `joins`, `windowFunctions`, `cursor` and `distinct` from queries and - the `{ field, fields, alias }` object form from `fields[]`; replace - `QueryBuilder.cursor()`/`.distinct()` call sites. Stop sending - `options.validateOnly` to `/batch`, `/updateMany` and `/deleteMany` — it never - previewed anything, it persisted. -- **Required fields:** run `os migrate meta` — it stamps - `storage: { notNull: true }` onto every pre-17 `required: true` field so your - columns keep their exact constraints. New fields: `required: true` alone - write-gates with a nullable column; add `storage.notNull` when you want the - DDL. Audit any client that PATCHes `null` into required fields — that write - is now rejected. -- **Membership:** move business capability off `sys_member.role` and onto - positions (`sys_user_position`). -- **SDK callers:** remove calls to `client.permissions.*`, `client.realtime.*`, - `client.workflow.*`, `client.views.*` CRUD, the notifications - device/preference helpers, `client.ai.{nlq,suggest,insights}` and - `projects.listTemplates()`; repoint marketplace publish to - `POST /api/v1/packages/publish`; drop `os environments create --template`. - Imports of `IWorkflowService`, `WorkflowProtocol` or the - `Get/WorkflowState/Config/Transition` types no longer resolve — the - `workflow` slot retired with them (#4451); use `state_machine` validation - rules, approval flow nodes and `record_change` flows instead. Discovery - responses no longer carry `services.workflow` / `routes.workflow` / - `features.workflow` — a reader keying on them saw only `unavailable`/`false` - before, so delete the read. -- **Console hosts:** the same removals land in objectui — drop the - `useClientNotifications` device/preference delegates (`@object-ui/react`) and - replace the retired `@object-ui/types` Capabilities re-exports with imports - from `@objectstack/spec`. If you build the Console from source, note the - `maplibre-gl` 5→6 / `chalk` 5→6 major bumps. -- **Type importers:** replace `ObjectStackProtocol` / `ObjectStackProtocolSchema` - with the narrowest per-domain slices; drop GraphQL types and any of the removed - dead spec clusters. If you imported `MetadataFormat`, `MetadataStats`, - `MetadataLoadOptions`, `MetadataSaveOptions`, `MetadataLoadResult`, - `MetadataSaveResult`, `MetadataWatchEvent`, `MetadataCollectionInfo` or - `MetadataLoaderContract` from `@objectstack/spec/kernel`, change the path to - `@objectstack/spec/system` — same names, and that copy is the one the runtime - has always emitted. (`MetadataExportOptions` / `MetadataImportOptions` moved - again in #4538: the system-side option bags turned out to have zero consumers - and were deleted, so those two names now come from - `@objectstack/spec/contracts` — the shape `MetadataManager` actually - implements.) It is - the *looser* of the two, so a reader may need new narrowing: notably - `metadataType`/`name`/`timestamp` are optional there. (`MetadataWatchEvent.type` - briefly also declared the raw watcher values `add`/`change`/`unlink`; they had - zero producers — both emit sites normalize — and were removed in #4536, so the - enum now carries exactly `added`/`changed`/`deleted`.) Nothing to migrate at - runtime — the values the runtime emits were always these. -- **Multi-org:** the `group` posture requires the enterprise runtime — deployments - relying on it self-activating must install `@objectstack/organizations` or move - to `isolated`. -- **Approvals readers:** anything that listed requests tenant-wide must now be a - participant or hold the admin override. -- **Raw-HTTP action callers:** branch on the HTTP status — non-2xx is the - failure, and a 200's `data` is the handler's return directly, one wrap less - (#3962). Crashes are 500s now; treat them as server faults. SDK callers - need no change. -- **List-query callers:** malformed filters and unknown - `sort`/`select`/`expand`/`groupBy`/`aggregations`/`searchFields` names now - answer 400 instead of silently over- or under-returning — fix the request - the error names, and note that previously-ignored `orderBy: string[]` and - `{field: direction}` sorts now actually apply. -- **Raw-`fetch` readers** of the settings / datasource-admin / - external-datasource / package / share-link routes: read the payload under - `data` and errors as `error.code` / `error.message` (#3843, #3983). -- **Bulk callers:** stay under `batch.maxBatchSize` (default 200, raisable to - 1000) or chunk; stop sending `object` / `context` in bulk bodies (ignored); - read `deleteMany`'s new structured `BatchUpdateResponse` (#3939, #3897). -- **Analytics clients:** send the bare `AnalyticsQuery` shape (`where`, not - `filters`; no `{cube, query, format}` envelope) (#3878). Embedded / - programmatic hosts must install `@objectstack/service-analytics` — the - context-less fallback is gone (#3891). -- **Bare `HonoServerPlugin` hosts:** mount `@objectstack/rest` or the runtime - dispatcher for data/discovery — the convenience surface and its flag are - deleted (#4073). -- **Direct `new InMemoryDriver()` / `driver: 'memory'` users:** pass - `{ persistence: 'file' }` if you relied on the accidental durability, and - delete stale `.objectstack/data/memory-driver.json` files (#4065, #4083). -- **`artifact-api` sources:** switch to the `local-file` URL form or - `os package install` — a configured `artifact-api` now fails the boot - (#4246). -- **Hand-built kernels:** compose `PlatformObjectsPlugin` if you relied on - storage/settings registering `sys_migration` / `sys_secret` (#4243, #4270); - declare the ADR-0116 ordering fields on plugins that resolve services in - `init()`. -- **SQLite operators:** back up with `sqlite3 app.db ".backup …"` — a bare - file copy can miss committed transactions in the WAL sidecars — and set - `OS_DATABASE_SQLITE_JOURNAL_MODE=delete` on NFS/SMB (#3941). -- **Dev setups:** the fabricating stubs are gone — install the real plugin - for any slot a dev call now finds absent, and note `plugin-dev` refuses - `NODE_ENV=production` without `OS_ALLOW_DEV_PLUGIN=1` (ADR-0115). -- **Sharing admins:** add the new `manage_sharing` capability to the permission - sets held by whoever administers sharing rules (`admin_full_access` carries - it already), and expect edit-level shares to stop conferring delete - (ADR-0111). -- **Flow authors:** rename aliased node-config keys (`subflow`/`map` - `flow` → `flowName`, notify `to`/`subject`/`body`/`url` → - `recipients`/`title`/`message`/`actionUrl`, script `functionName`/`input` → - `function`/`inputs`) or run `os migrate meta` — the conversion windows - close at protocol 18 (#3796, #4278, #4045). -- **Spec importers (dual-source renames):** every break here is a compile-time - `TS2305` naming the symbol, and none of it is authorable metadata, so - `os migrate meta` has nothing to do. Change the **import path** for - `WebhookConfig`/`WebhookEvent` (→ `./integration`), `MetadataEvent`/ - `MetadataBulkRegisterRequest` (→ `./api`), `Notification`/`NotificationConfig` - (→ `./api`), `Session` (→ `./api`), `EventSchema` (→ `./kernel`). Change the - **name** for `PackageDependencySchema` on `./kernel` (→ - `ResolvedPackageDependencySchema`), `RateLimitConfig` on `./integration` (→ - `ConnectorRateLimitConfig`), `FieldMapping` on `./integration` / `./data` (→ - `ConnectorFieldMapping` / `ImportFieldMapping`), `ConflictResolution` on - `./integration` (→ `ConnectorConflictResolution`), `HttpMethod` on `./ui` (→ - `HttpMethodType`), `ActionLocationSchema` on `./studio` (→ - `ActionContributionLocationSchema`), `ShareRecipientType` on `./contracts` (→ - `RecordShareRecipientType`). Deleted outright: the `./automation` `DataSyncConfig` - family, `./system`'s tenant-provisioning family with `IProvisioningService` / - `ITenantRouter` / `ResolvedTenantContext`, and the orphan notification-template - schemas (use `EmailTemplateDefinition` and siblings). -- **Datasource authors (again):** delete `retryPolicy`, `healthCheck`, - `capabilities`, `external.label` and `external.requirePermission` — - `os migrate meta --from 16` removes all of them. None ever had an effect; - `capabilities.readOnly` in particular never made a datasource read-only. - Then verify the **mapping**: an object mapped to a datasource with no live - driver now throws instead of silently reading and writing the default store, - so a mapping you have been carrying decoratively will surface at boot. -- **Driver implementors:** delete `findStream` (the required method nothing - called) and the 31 retired `DriverCapabilities` bits; three bits with real - readers remain. Delete any `IDataEngine.batch` implementation and route - multi-write atomicity through `engine.transaction(cb)`. -- **Bulk/batch callers:** read per-row results under the declared shape — - `errors: ApiError[]` (not `error: string`), `data` (not `record`), plus the - `index` that was never sent. `options.atomic` now **defaults to `false`** and - is a real guarantee when set: an atomic batch either rolls back completely - (zero successes, rows marked `ROLLED_BACK:` / `NOT_ATTEMPTED:`) or is refused - with 501 on a runtime that cannot transact. If you were passing - `atomic: true` and relying on partial results surviving, switch to - `atomic: false`. `deleteMany` / `updateMany` behave identically now (#4620). -- **Hook authors:** a `condition` that cannot be evaluated **aborts the write** - instead of skipping the hook — grep your conditions for keys that are not - declared fields before upgrading. Conditions now read the merged record - (stored ⊕ payload), so `record.x == v` is true on every update of an - already-matching row; write transitions with the new `previous` binding - (`previous.done != true && record.done == true`). -- **Validation authors:** a `script`/`cross_field`/`conditional` rule whose - predicate faults now **rejects** the write instead of being skipped. Expect - previously-silent rules to start firing; that is the defect being fixed, not - a regression. Lower `severity` to `warning`/`info` only where blocking is - genuinely wrong. -- **Flow authors (`script` nodes):** `config.function` is required. Replace - `actionType: 'email' | 'slack'` and their `template`/`recipients`/`variables` - with a `notify` node against a real messaging service; replace inline - `config.script` with a registered function — it never executed. -- **Standalone validation artifacts:** delete `*.validation.ts` files and the - Studio Validations entries built from them, and move each rule into the - object's own `validations[]`. Nothing authored through that door ever gated a - write — including `state_machine` rules. -- **Job authors:** jobs can no longer be created at runtime or overridden per - org. Move any runtime-created job into `defineStack({ jobs, functions })` so - its `handler` resolves against a real function. Existing `sys_metadata` rows - are left untouched — they were never scheduled — and now report `skipped`. -- **`managedBy` authors:** rename `'system'` → `'system-data'` - (`os migrate meta --from 16` does it; stored rows are converted, never - reinterpreted). The bucket defaults **writable**, so delete now-redundant - `userActions: { create, edit, delete }` blocks and keep `userActions` only to - narrow. -- **App authors:** delete `areas[].visible` and `areas[].requiredPermissions` — - both failed open, so anything you were gating with them was visible to - everyone. Re-gate item-by-item inside `areas[].navigation`, which is now - filtered server-side. Also expect gated items to be **absent** from the - `/meta` payload rather than present-and-hidden. -- **Comment consumers:** `sys_comment` is now gated by the record its - `thread_id` names; `visibility` and `reply_count` are removed. A UI that read - `visibility` to decide what to show should stop — it never decided anything. -- **Automation hosts:** `await` `IAutomationService.getSuspendedScreen(runId)` - and make test doubles resolve rather than return (#4515). -- **Runtime metadata writers (Studio / `/meta` / MCP agents):** an active - **flow** write is now linted and refused with 422 `INVALID_METADATA` when a - gating rule fires. Fix the metadata; `OS_ALLOW_UNLINTED_METADATA_WRITES=1` - buys a migration window and should not outlive it. -- **Datasource and connector authors — do this one first.** Inline credentials - are refused at publish. Move `config.password` / `config.authToken` (and the - alias spellings `passwd`, `pwd`, `token`, `jwt`, `auth_token`, `authtoken`) - and any `user:password@host` userinfo in `config.url` into the secret store, - and reference the handle with `external.credentialsRef` — Setup → - Datasources binds it for you. Do **not** substitute a `${…}` placeholder: - placeholders in authored metadata are resolved by nothing and reach the - database client verbatim, so that form is refused too. Connector descriptors - must drop a non-`none` `authentication` and document the scheme in prose; - instances reference a `credentialRef`. There is deliberately no codemod — - `os migrate meta` reports both as structured TODOs, because auto-deleting the - key would silently drop a live credential. Rows already stored in cleartext - are a separate programme (follow-up under #7990); this release closes the - doors that keep writing new ones. -- **Driver names:** `mongo` → `mongodb`, and pin `OS_DATABASE_DRIVER` to a name - the single vocabulary knows. `os start` and `os migrate` now read the same - table, so an alias one accepted and the other refused (`pg`, `libsql`) is no - longer a coin flip, and the datasource factory can no longer fall through to - `memory` on a name it does not recognise. -- **Memory-driver deployments:** it now declares itself single-tenant and - refuses to boot multi-tenant. It never implemented row-level isolation, so if - this refusal fires, that deployment was serving cross-tenant reads, updates - and deletes silently. Move to a driver in the tenant-chokepoint scan - (`driver-sql`, `driver-sqlite-wasm`, `driver-turso`). -- **API callers:** unknown query parameters are now refused on the first tier of - data read routes rather than dropped. Audit any integration that passes - parameters the route never declared — the tolerated traffic that used to get a - plausible `200` is exactly what this breaks. Also drop `cursor` from - `GET /api/v1/notifications` (it paginated nothing) and stop reading - `config.features.passkeys` / `.magicLink` off `GET /auth/config`. -- **Text filters on MongoDB:** `$contains` / `$notContains` / `$startsWith` / - `$endsWith` are case-**sensitive** now, on this driver and the memory driver - as they already were on SQL. Row sets change. Where you want the fold, use - `$icontains`; where a filter feeds an RLS read scope, note the old behaviour - was over-reach, not merely a loose match. -- **List-view authors:** rewrite `exportOptions: ['xlsx']` to the object form - `exportOptions: { formats: ['xlsx'] }` — the bare array was type-legal and - non-functional, and the renderer fell back to `['csv','json']`. Drop `'pdf'` - (declined platform-side, #1301) and delete `striped` / `bordered` / - `virtualScroll`, which were copied down the chain and applied by nothing. -- **Runtime field creation:** a standalone `PUT /meta/field/{object}.{name}` - now answers 403 `NOT_CREATABLE` instead of a `200` that persisted a row the - object never showed. Write the whole object instead — - `PUT /meta/object/{name}` with the entry in `fields` — which is unchanged and - still supports runtime creation. -- **Package uninstall:** `deletePackage` refuses a call that names neither an - organization nor `allTenants`. Callers that omitted the organization were - deleting every tenant's rows; decide which you meant and say so. -- **Engine callers:** delete `upsert` from `engine.update()` option bags — it - was never implemented. Express create-if-absent explicitly (`findOne`, then - `insert` or `update`), and note that the by-id update branch now throws - `RECORD_NOT_FOUND`. -- **Number fields:** a declared `scale` is enforced by rejection. A field - declared `scale: 0` that has been accepting `11.5` now answers 400 - `VALIDATION_FAILED` with field code `max_scale`. Nothing rounds — check your - ingest paths, CSV import included, before upgrading. -- **Paused-node executors and `api` authors:** a pause node whose descriptor - never declared `resumeAuthority` is now fail-closed on the generic resume - route — declare `'any'` to opt back in. `api` is code-only: move any - runtime-created endpoint into `**/*.api.ts` or `defineStack({ apis })`. -- **Audit-log consumers:** `export`, `permission_change` and `restore` leave the - `sys_audit_log` action enum — none ever had a writer. In the other direction, - expect **new** rows you were not seeing before: sign-in/sign-out, settings - `config_change`, the full metadata lifecycle, and package-publish rows - including refusals. -- **Everyone:** run `os validate` before upgrading. The ADR-0078 completeness - rules are new **errors**, and they fire on metadata that has been parsing - cleanly for majors — a `summary` field with no `summaryOperations`, a - `formula` with no `expression`, a relationship with no reference, a - `select`/`radio` with no options. Each finding is a field that has been - computing nothing. - - -### 17.1.0 - -⚠️ Despite the minor version number, four of these are behaviour changes on live -data or on a published wire contract. Work through them before upgrading. - -- **Audit your deactivated permission sets and positions FIRST (#8613, #8710).** - Any `sys_permission_set` or `sys_position` row currently carrying - `active: false` **stops granting the moment this lands**, and a deactivated - position named as a sharing-rule recipient has its `sys_record_share` rows - **revoked on the next evaluation**. That is the correct direction — it is what - the Deactivate dialog always said — but on an installation that used the switch - believing it was inert it is a real revocation, with no migration step to - notice. List them 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]] - GET /api/v1/data/sys_sharing_rule?filters=[["recipient_type","=","position"]] - ``` - - Rows whose `active` column is absent or NULL are unaffected. -- **Grant `allowExport` explicitly wherever admin export is intended (#8681).** - The platform admin sets no longer carry the `'*'` export wildcard, and nothing - fails at parse time — a deployment that upgrades without editing anything is - valid metadata whose administrators have quietly lost export on every object no - app set names. There is deliberately no automatic replacement. Author it in - **your** app's permission set (platform sets are not overridable): - - ```ts - { - name: 'system_admin', - objects: { - crm_account: { allowRead: true, allowExport: true }, // export intended - crm_quote: { allowRead: true }, // export withheld - }, - } - ``` - - 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. -- **Re-run `os build` / `os validate` after upgrading.** Several new refusals can - fail a stack that built clean on 17.0.0: an unknown **top-level stack key** - (#8687 — check for a typo or a stale key such as `flow` for `flows`, which until - now shipped an artifact with that whole family absent at runtime); a dashboard - header `modal` action whose target names an action, a bare object or a - `_` form rather than a declared **page** (#9013 — those buttons - already dispatch to a refusal at runtime; there is deliberately no automatic - rewrite, and `os migrate meta` surfaces it as a structured TODO); a **sharing - rule** anchored on an object whose effective sharing model is `public` or which - is a master-detail detail (#9698); a list-view **`sort`** naming a formula field - or no field at all (#9257); a bare **date-range preset** in an ordering filter - comparand (#8793); and undeclared keys on the analytics surface, on object - `indexes[]`, on `Field.inlineColumns` / `Field.relatedListColumns` and on five - more `record:*` component prop maps. -- **Studio and metadata-API writes are now judged by the same rules.** If you - publish through `PUT /api/v1/meta/…` or Studio rather than the CLI, expect - writes that previously returned `200` to be refused — dashboard widget bindings - (#7529) and the five gating object rules now run at the publish door. -- **Flow callers: branch on the HTTP status, not on an inner `success` flag.** - The automation `trigger` routes, `POST /api/v1/actions/:object/:action` and - declared `type: 'flow'` endpoints all answer `404` / `409 FLOW_DISABLED` / - `422 FLOW_NO_START_NODE` / `400 FLOW_FAILED`. A caller that treated `400` as - "the run failed", or that read a declared endpoint's result out of a `200` body, - needs updating — a declared endpoint's failures are now 4xx with the platform - error envelope and no inner `data.success` to read. `outputMapping` is no longer - applied to a failure. If you call the resume route, send only `inputs`, - `variables`, `output` and `branchLabel`: any other top-level key is now a `400`. -- **Apps that throw their own `error.code` should read `declaredCode` (#9106).** - A thrown code outside `StandardErrorCode ∪ ERROR_CODE_LEDGER` no longer reaches - `error.code`; it arrives on the new optional `declaredCode` instead. One-line - fix: branch on `error.declaredCode` for app-specific spellings and on - `error.code` for platform conditions. Platform producers are unaffected. -- **Retired surfaces.** `sys_user_permission_set.delegated_from` is gone (#9730); - `element:filter` (#9220), `targetVariable` on `element:text_input` / - `element:record_picker` (#9198), `MetadataPluginConfig.additionalTypes` (#8586) - and `ApiKeySchema` (#8715) are retired; and `BATCH_PARTIAL_FAILURE`, - `BATCH_COMPLETE_FAILURE` and `TRANSACTION_FAILED` leave `StandardErrorCode`. - Console hosts: `app-shell` is no longer a component key, ``'s - `description` prop is gone in favour of `subtitle`, the structured `confirm` - object on actions is retired in favour of `confirmText`, and - `dashboardComponents` is re-keyed to the eight schema `type` keys. -- **`file-storage` → `storage` (#9683).** The canonical `CoreServiceName` slot is - now `storage`. `file-storage` keeps working as a deprecated alias for the v17 - line and retires at the next major; a custom v17 provider for this slot should - register **both** names. -- **API keys under `isolated` tenancy (#8287).** Keys minted before this release - carry no organization and are **refused** under `OS_TENANCY_POSTURE=isolated`, - where they already read nothing. They are deliberately not backfilled — re-mint - them. Keys keep working under `single` and `group`. -- **If a config read `process.env.OS_ARTIFACT_PATH` expecting the CLI to have - populated it (#8985)**, 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 rung it has always occupied; `os start` / `os dev` simply no longer - manufacture it on your behalf. -- **Automations:** a triggered run now carries the flow author's - `successMessage` / `errorMessage`, and a flow ACTION that ran and failed carries - `errorMessage` and the run `summary` in `error.details` — review anything that - parsed the previous generic text. - -### 17.2.0 - -⚠️ **Not exercised.** No upgrade across 17.1.0 → 17.2.0 has been walked -end-to-end. Every line here is derived from the change's own **Migration** note -in [Breaking changes & migration in -17.2.0](#breaking-changes--migration-in-1720); none of it carries a measurement, -and the effort each one costs on a real deployment is unknown. Treat this list as -a reading order for that section, not as a walked path. - -- **Audit every by-id write whose `where` names `id` plus other keys (#11009, - #11142).** This is the one that used to fail silently: a compare-and-set like - `{ where: { id, status: { $in: [...] } } }` dropped every key but `id` and - landed unconditionally. Declare `multi: true` to have the full `where` honoured - (the result becomes a matched count), or drop the extra keys to keep an - unconditional single-row write. **Flow authors reach this through - `update_record` / `delete_record` nodes** whose `filter` names `id` alongside - other keys — those configs were unconditional before and refuse loudly now. -- **Make `data.id` and `where.id` agree**, or drop one of them. A by-id update - naming two different rows answers `UPDATE_ID_MISMATCH` / `400`, including ids - that differ only in type (`42` beside `'42'`). -- **⛔ Rewrite any dashboard, alert rule or recording rule keyed on - `http_request_errors_total` (#9834).** It is retired, and nothing throws or - warns: the series simply stops receiving samples, so the panel reads a **flat - zero** that looks like a healthy server. Move to - `http_requests_total{status=~"5.."}`, which the transport emits and which - therefore covers every inbound surface rather than the dispatcher's routes only. - Delete any host-code read of `SEMCONV.httpRequestErrorsTotal` / - `RUNTIME_METRICS.httpRequestErrorsTotal`; `tsc` will point at the site. -- **Delete four retired authorable keys, then re-run `os validate`**: - `sys_position.permissions`, `MetricSchema.filters`, the `record:highlights` - highlight-field `icon`, and the `themes` carrier key. Each is now refused **by - name at parse** rather than silently dropped, so a stack carrying one fails to - build. Deployed database columns are untouched. -- **Grant the federation capabilities to any credential calling - `datasources.external.*`** (or the CLI's `datasource` commands). These routes - admitted any authenticated caller before and now require - `manage_platform_settings` or `manage_metadata` per route, answering `403 - PERMISSION_DENIED` by name. `admin_full_access` already carries both; a - purpose-built operator set is the case to check. -- **Remove `os g agent` from any script, Makefile or CI step** — it is retired and - exits 1. Author skills instead. -- **Re-check analytics filters nested inside `$or` / `$not` / a nested `$and`.** A - cross-object filter in that position used to reach `aggregate` unchecked and - answer the wrong number; both analytics doors and a dataset's definition-level - `filter` now refuse it the way a top-level one already did. -- **A publish naming `?package=` no longer finds a package-less draft** — it - answers `404 [no_draft]`. If the draft you mean carries no package binding, - retry the publish without the query parameter. -- **Hosts injecting their own logger sink** must supply `warn`; it is - non-optional on thirteen sink types now. Compile-time only. -- **Callers of `IHttpOutbox.redeliver` / `MessagingService.redeliverHttp`** move to - the new signature (the caller's tenant is threaded rather than swept globally), - and a delivery row claimed between read and write now reports - `DELIVERY_NOT_ELIGIBLE` instead of success. -- **`meta.getLegalNextStates` is singular** — the plural route registration is - retired. - -### 17.3.0 - -This is the largest release of the v17 line — 862 changelog entries, 100 of them -self-marked breaking — and the only one on this page whose checklist was written -from a **measured upgrade**. Lines marked **measured** were walked on a real -application moving 17.2.0 → 17.3.0 using published artifacts only; lines marked -**not exercised** are derived from a change's own **Migration** note in [Breaking -changes & migration in 17.3.0](#breaking-changes--migration-in-1730) and were not -reached by that run. - -**Getting onto the release** - -- **Move all twelve-or-so `@objectstack/*` pins as one set, regenerate the - lockfile, and leave `specVersion` / `engines.protocol` alone** — this is a move - inside one major, so the protocol declarations do not change. *Measured:* this - step is not a tag swap for an npm-consuming app, and until now no page described - it; the full three-step procedure is now [Moving the dependency - pins](/docs/upgrading#moving-the-dependency-pins). -- **Expect `os migrate meta --from 17` to report `Nothing to migrate`, and do not - read that as completion.** *Measured:* it exited 0 with exactly that line while - **nine** of the items on this page were live and unaddressed on that stack. Its - scope is the protocol chain; everything else on this list is outside it. (It - also prints `runtime 17.0.0` on a 17.3.0 install.) - -**Metadata and build — run `os validate` before you ship** - -- **The author-time rule count moves 41 → 42, and the new rule can refuse a stack - that built clean on 17.2.0 (#14747).** *Measured:* - `security-controlled-by-parent-ambiguous-relation` refuses an object that - declares `sharingModel: 'controlled_by_parent'` when two or more of its fields - tie for the master relation — because the resolver picks the master with - `Array.prototype.find`, which makes the security boundary depend on **field - declaration order**, silently. The remedy the diagnostic names first is to - promote the intended master into the required `master_detail` tier so only one - candidate remains; demoting the other candidate works equally. The CLI - diagnostic names the rule, the ADR, both candidates and which one currently - wins — read it rather than guessing. -- **Re-run `os validate` for the rest of the author-time refusals**, which are - parse-time and name the key: strict plugin manifests, retired - `page.components[].responsive`, `allowRestore` / `allowPurge`, `lookup` / - `master_detail` requiring a non-empty `reference`, `null` refused in `$in` / - `$nin` / `$between` / ordering comparands, undeclared keys on `address` and - `location` values, `radio` with `multiple: true`, and the ADR-0049 retirements - whose whole migration is "delete the key". Full list under [Author-time - refusals that can fail a stack which built clean on - 17.2.0](#breaking-changes--migration-in-1730). *Not exercised* beyond the rule - above. -- **Re-run your i18n gate — the lint family widened onto surfaces it did not read - before.** *Measured:* zero findings to **429** on one app, across dataset - labels / descriptions / dimensions / measures, object validation messages, view - bulk actions and page component labels. `os lint` itself still exits 0 (these - are warnings); a deployment enforcing its own zero-tolerance gate is what turns - red. ⚠️ **This is a one-way door for the translation files:** 17.3.0 both - demands these keys and accepts them, while 17.2.0 refuses them - (`Unrecognized key(s) on this locale of the translation bundle: 'datasets'`, and - likewise for `_validations` and `bulkActions`). Translations authored to clear - this gate cannot ride back to 17.2.0. - -**Data and database** - -- **⛔ On an existing database, deduplicate your autonumber columns before the new - unique indexes can build.** *Measured.* An `autonumber` field that omits - `unique` now parses to `unique: 'organization'` (`f8e8f03`, #13894) and the - drivers enforce it (#13197, #13239), so a table already holding duplicate - auto-numbers **cannot take the index**. The driver neither fails the boot nor - skips silently: it logs on the `error` channel, and the drift pass names the - conflicting key groups with row counts. Run `os migrate duplicates`, - deduplicate, then re-run `os migrate plan` — **until you do, that constraint is - not enforced**, so a boot that came up cleanly is not evidence the index exists. - A database created fresh took all ten indexes with zero errors. -- **Back-fill `sys_record_share` (#14484).** The object is tenant-scoped now, so - pre-existing grant rows need attention. *Not exercised.* - -**Deployment and configuration** - -- **⛔ A walled deployment (`OS_TENANCY_POSTURE=group` or `isolated`) must declare - `OS_PLATFORM_OWNER_EMAIL`, or it will not boot.** *Measured:* `AuthPlugin.init()` - refuses to start without it. Walled postures now elevate only the env-declared - platform owner and never the first registrant (`9735662`, #11184), and the - bootstrap no longer mints the org-less grant row at all — `hasPlatformAdminGrant` - is read off the resolver's posture verdict rather than recomputed from grant - rows. If a test or a fixture leaned on implicit first-user promotion, give that - actor an authored grant instead. -- **Decide your audience posture explicitly (`4f24e9d`).** It collapses to - `invite_only | email_domain | open` and **defaults to `invite_only`**, so a - deployment that relied on open self-registration must declare it. *Not - exercised.* -- **A permission-store read failure now fails LOUD (#13279).** A deployment that - had been quietly running degraded starts erroring. *Not exercised.* -- **`POST /api/v1/automation/:name/toggle` requires `manage_metadata` - (`266436a`).** *Not exercised.* - -**Application code, hooks and flows** - -- **Search your hooks for `delete ctx.input.` — it was a no-op and now - actually deletes (#12277).** *Measured.* This shipped graded `minor` - deliberately, and its own changelog says it: any hook already containing that - statement has been inert until now and starts taking effect on upgrade. Both - mechanisms closed together — the in-process flat-record Proxy grew a - `deleteProperty` trap, and the sandbox path diffs deletions against the entry - snapshot instead of using `Object.assign`, which cannot represent a removal. If - a hook needs a column to arrive `null` rather than absent, it must **assign**, - not delete. -- **An undeclared key written by a hook is now refused by every driver - (`b003cf2`).** *Measured.* Through 17.2.0 `driver-memory` accepted and stored a - key the object never declared — outside field-level security by construction, - since an undeclared field can carry no `fieldPermissions` entry — while the - SQLite drivers refused it as a raw error carrying neither an ADR-0112 `code` nor - a `status`. All three now answer `INVALID_FIELD` / `400`. Real behaviour change - for any app whose hook quietly wrote an undeclared key on `driver-memory`. -- **A flow value expression naming an unknown function now refuses the run - (#11060).** *Measured.* The evaluator knows its own closed vocabulary - (`round` / `floor` / `ceil` / `abs` / `min` / `max`, plus `NOW()` / `TODAY()`), - and anything else is a loud refusal. This deletes a genuinely dangerous silent - branch: `TOMORROW()` used to be rewritten to null and the field written - `undefined`, and the unwrapped form `LOWER({x})` interpolated to the **literal - text** `LOWER(ACME Corp)` and landed in the database looking as though it had - worked. -- **Tests that pin the record-level `FORBIDDEN` message text will fail.** - *Measured.* The message is user-facing prose now - (`insufficient privileges to update crm_contract` became `You do not have access - to change or delete this record. …`). `code` and `status` are unchanged, which is - the switchable contract — pin those tightly and the message loosely. -- **Hook bodies can now see they are on a per-row dispatch (#11552).** *Measured.* - `ctx.dispatch` (`{ mode, index }`) and a frozen `ctx.input.options` - (`{ multi, where }`) projection cross the sandbox boundary; `ctx.input.id` still - does not, and `scope` does not cross with `dispatch`. This is a capability - rather than a break — but a test pinning their **absence** goes red, and that - red is the blocker lifting. -- **Deep imports into `@objectstack/cli` subpaths stop resolving - ([#15325](https://github.com/objectstack-ai/objectstack/issues/15325)).** - *Measured.* 17.2.0 published no `exports` field; 17.3.0 adds one admitting only - `.` and `./console`, so an import of anything else fails `tsc` with TS2307 and - Node with `ERR_PACKAGE_PATH_NOT_EXPORTED`. `@objectstack/cli/package.json` is - sealed too. If you depend on an unratified subpath, resolve the package root - (which the map does admit) and load the file relative to it while the subpath is - considered for ratification. -- **Move `client.projects.*` to `client.environments.*` (#12866, #12882, - ADR-0006 D2).** *Not exercised* — the measured app calls no control plane. **No - aliases exist and none are coming**; response keys move with the methods, and - `os environments --format json` changes shape. The full method and key table is - in [the detail section](#breaking-changes--migration-in-1730) — use it rather - than paraphrasing it. -- **Four SDK methods stop handing you the dispatcher envelope (#13079)**, and a - third `UPDATE_ID_MISMATCH` shape refuses — a truthy scalar `data.id` beside a - declared but non-scalar `options.where.id` (`5d16379`, #11230), the shape - 17.2.0 deliberately left standing. *Not exercised.* - -**Measured and found clean** - -These were checked on the same run and needed no action. They are recorded -because "we checked X and it was fine" is a checklist input too: seed data loaded -unchanged on a fresh database (the predicted "our seed data stopped loading" from -newly-enforced uniqueness did **not** materialise); a fresh boot produced zero -`ERROR` lines with all ten unique indexes present; `os migrate meta` was correct -as far as its scope goes; and the growth in non-gating `os validate` advisories -(54 → 95) was pre-existing shapes newly reported, not new defects. - -## References - -ADR-0104 (field runtime value-shape contract / file-as-reference) · ADR-0105 -(group tenancy posture) · ADR-0106 (metadata-plane FLS, proposed) · ADR-0108 -(membership grade is not capability) · ADR-0109 (agent tools from skills) · -ADR-0076 D9/D11 (protocol alias dissolution, dispatcher decomposition) · -ADR-0087 D4 (change manifest / `migrate meta`) · ADR-0049 (enforce-or-remove) · -ADR-0078 (loud at the producer) · ADR-0090 D3 (`team` recipient) · -#3825 (Node 22) · #3544/#3710 (export axis) · #3543/#3391 (ApiMethod -derivation) · #3760 (user-less runs) · #3855 (alias retirement) · -#3820 (agent authoring) · #3590 (approval visibility) · #3865 (sharing `full`) · -#3617 (files-to-references migration) · #3447 (dynamic approver routing) · -#3563/#3587/#3612/#3718 (route ledger + SDK surface) · #2462 (GraphQL removal) · -#3741/#3758/#3826 (datasource fail-fast) · #3696 (per-tenant `unique`) · -#3676/#3778/#3847 (i18n contract conformance). - -Landed since rc.0: ADR-0110 (action declaration admission) · ADR-0111 (sharing -authority) · ADR-0112 (error-code vocabulary) · ADR-0113 (`required` split) · -ADR-0114 (field-error catalog) · ADR-0115 (no fabricating fallbacks) · -ADR-0116 (declared plugin ordering) · ADR-0053 (temporal semantics) · -#3962/#3951 (actions speak HTTP) · #3915 (action type dispatch) · -#4121/#4134/#4164/#4181/#4226/#4254/#4256/#4363 (list queries apply or fail) · -#3795/#4346/#4371 (one alias fold) · #3899/#3878 (request-body validation) · -#3939/#3897/#3933/#3946/#3960 (bulk binding + caps) · #3843/#3983/#4038/#4053 -(envelope convergence) · #3957 (localized validation) · #4093/#4113/#3891/#4087 -(honest absence) · #4073 (Hono transport adapter) · #4065/#4083 (memory-driver -persistence) · #4246 (`artifact-api` removal) · #3903 (stored-metadata -conversion replay) · #4277/#4045/#4027/#4347 (flow config enforcement) · -#3948/#4029 (view-filter operator parity) · #4157/#4128 (analytics stops -guessing) · #3916 (report ordering) · #4350 (protocol-17 relabel) · -#4127/#4251 (typed service slots + fail-open fixes) · #3917/#3924 (`os migrate` -occupancy + deferred DDL) · #4243/#4270 (platform-objects infrastructure) · -#4395/#4396 (unmeasured effects) · #4365/#4366 (approval reassign + audit -attribution) · #4261/#4248 (published-files hygiene). - -Landed since rc.1: ADR-0118 (non-user actor contract) · ADR-0119 -(plugin-reachable transactions, migration journal) · ADR-0088 (metadata-kind -admission) · ADR-0103 (`managedBy` buckets) · #4535 + #4411 (dual-source -convergence C1–C17: #4572, #4587, #4610, #4641, #4653, #4658, #4661, #4684, -#4688, #4691, #4703, #4737, #4738, #4739, #4740, #4741) · #4537/#4538/#4539 -(enum, contracts and cross-form convergence) · #4446 (symbol-identity ratchet) · -#4001 final batches (#4514/#4519/#4522/#4527/#4528/#4529/#4530/#4531/#4532/#4533/#4534/#4541) · -#4544 (ADR-0078 completeness, Phases 1/3/4: #4501, #4565, #4574, #4599) · -#4463 (runtime authoring gate) · #4409/#4487/#4488 (rule + liveness coverage) · -#4583/#4634/#4484/#4618/#4579/#4657/#4673/#4616 (enforce-or-remove: datasource, -driver and contract surfaces) · #4509 (validation kind, job door, doc.tags, -email-template bridge) · #4667 (authorWarn keys) · #3355 (`system-data`) · -#4612/#4617/#4620 (atomicity + migration journal) · #4793 (batch row shape) · -#4639/#4626/#4602/#4678 (event contracts + client-react) · #4343 (`script` -node) · #4649/#4770/#4775/#4784 (predicate and condition semantics) · -#4419 (`findOne`) · #4462/#4410/#4456 (datasource routing + config contract) · -#4630/#4651/#4722/#4757 (security corrections) · #4433/#4434/#4640/#4669 -(sharing + permission corrections) · #4467/#4437/#4442/#4708/#4820 (analytics) · -#4327/#4454/#4542 (stored-metadata migration). - -Landed since rc.2: ADR-0122 (parsed/author state naming: #5551, #6350 phase 2) · -ADR-0045 (app publish gate: #4829) · ADR-0097 §3 (connector credential -references) · #4936/#4939/#5111/#5040 (declarative `apis:` goes live) · -#6345 (one driver vocabulary, `mongo` → `mongodb`) · -#6212/#6075/#6320/#6321 (driver query contract narrowing) · -#5488 (`api` is code-only) · #5561 (`resumeAuthority` fail-closed) · -#6361 (notification `cursor` retirement) · #5775/#6776 (SDUI component props) · -#6239 (`ViewProtocol` retirement) · #6188 (aggregation function narrowing) · -#5051 (`composeStacks` i18n) · #5945 (`HookContext.api`) · #6139 -(`HierarchyScopeContext` posture) · #5599 (view union identity) · -#4671 (`system-data` import) · #5696/#5351 (transaction tightening) · -#6536 (`ExportFieldMeta`) · #6704 (`runAutomations` default) · -#6748 (`ActionDescriptor.isAsync`) · #6771 (overlay index producer). - -Landed since rc.6: ADR-0123 D2 (active-organization write refusal: -#8247/#8208) · #7990/#8082/#8336/#8075 (inline credentials refused at publish) · -#7986/#7799/#7722/#8022/#8542/#8558 (webhook credential handling) · -#7522/#8273 (settings crypto fail-closed + wire code) · #7728 (`internal: true`) · -#8136/#8333/#8441/#8442/#8443/#8086/#8502 (driver-text disclosure) · -#8323/#8468/#8554/#8555/#8577/#8459/#8375 (per-organization uniqueness) · -#7626 (expand disclosure) · #7835/#7738/#8119 (federated tenancy) · -#8095/#8240/#7761/#7795/#8158 (org-scoped sharing + invitation reads) · -#5222/#7597/#7598/#8220/#7929/#7988 (`$field` cross-field comparison) · -#7606 (closed query-parameter ingress) · #6682/#6518 (`$contains` case -sensitivity) · #8010 (`view.exportOptions`) · #7176 (list-view passthrough -keys) · #7893 (`field.allowRuntimeCreate`) · #8057 (`engine.update` upsert) · -#7481 (auth config flags) · #7596 (list comparand references) · #7496 -(`submitBehavior.url`) · #7780 (cross-tenant uninstall) · #6915 (memory-driver -tenancy) · #7501/#7918/#7768 (number and currency contracts) · -#8445/#8567/#8592 (unbacked conflict targets) · #8269/#8283 (autonumber) · -#7580/#5346/#5376 (MongoDB query surface) · #8144/#8145/#8594/#8400 (audit -writers) · #7533/#8050/#7639/#7546 (automation run durability) · -#8368 (`OS_ARTIFACT_URL`) · #5320/#8070 (`viewItems:`) · #7616/#8326 (security -service surface) · #8600/#7891 (authored OWD at the object door) · -#8309/#8307/#7220 (security posture at runtime publish) · #7751 (`object-*` -block props) · #8315/#7675 (audit action enum) · #7735/#8019 (change-email) · -#7762/#7978/#7685 (verification harness). - -**17.1.0** — ADR-0049 (enforce-or-remove) · ADR-0055 (`controlled_by_parent`) · -ADR-0066 D3 (field `requiredPermissions`) · ADR-0067 (revert plans) · ADR-0068 -(platform-admin gate) · ADR-0090 D1 (OWD baseline) · ADR-0105 D7 (extension -fields) · ADR-0110 D3 (failure is not emptiness) · ADR-0111 D7 (inert grants) · -ADR-0112 (error-code vocabulary, amended 2026-08-18) · ADR-0120 D3 (partitioned -uniqueness) · #8613 (`active` stops granting) · #8710 (deactivated position, -sharing rules) · #8681 (admin export wildcard) · #8993 (partial field masking) · -#8652 (read-only approval visibility) · #8155 (credential re-homing) · #8287 -(API-key organization) · #8778 (`tenancy.organizationField`) · #9934 -(`userMessage`) · #9106 (`error.code` closed at every door) · #9446 / #9462 -(flow-dispatch status table) · #8896 (four read seams) · #7529 / #9612 / #9176 -(runtime publish gate) · #8687 (strict top-level stack keys) · #9013 (dashboard -`modal` target) · #9698 (sharing-rule enforceability) · #9257 (list-view `sort`) · -#8934 (`icontains`) · #8704 (`relatedListFilter`) · #9566 / #9474 -(`ActionSchema.onSuccess`) · #9225 / #9205 (email template rendering) · #8928 -(`os migrate duplicates`) · #9666 (scaffolds compile) · #8978 (ready banner) · -#8985 (`OS_INTERNAL_ARTIFACT_PATH`) · #9683 (`storage` slot) · #9730 -(`delegated_from`) · #8926 / #8807 / #8755 / #8622 / #9354 (MySQL driver) · -#9040 / #8337 / #9041 / #9091 / #9147 / #8495 (credential refusals at publish) · -#8873 / #8874 / #8696 (credentials reach the driver). diff --git a/content/docs/releases/v17/17.0-prerelease.mdx b/content/docs/releases/v17/17.0-prerelease.mdx new file mode 100644 index 0000000000..3921ef1839 --- /dev/null +++ b/content/docs/releases/v17/17.0-prerelease.mdx @@ -0,0 +1,1688 @@ +--- +title: 17.0.0 pre-release train +description: "Archive: what landed between 17.0.0-rc.0 and rc.6, before the 17.0.0 GA cut. Kept for provenance; nobody upgrading between shipped versions needs this page." +--- + +Archive of the pre-GA train for 17.0.0. ⚠️ These sections describe changes **between release candidates**, all of which are contained in the shipped [17.0.0](/docs/releases/v17/17.0). If you are upgrading between published versions, this page is not for you. + +### Landed since 17.0.0-rc.0 + +These changes are on `main` after the `rc.0` cut and **roll into 17.0.0-rc.1** — +backend from the pending changesets (334 at the time of writing, 40 of them +`major`-class), Console from the objectui pin advancing +`4a4829d0ef39 → 7d9734d5e321` (79 commits, released as **objectui 17.1.0**). +Sourced from each repo's own changesets. + +One reading note: this page documents the whole 17.0.0 train and has been kept +current as the train moved, so the *largest* post-rc.0 landings are already +documented in place in the sections above rather than repeated here: + +- **ADR-0110 action-declaration admission** (#3935) — including its post-rc.0 + revision: the `OS_ALLOW_UNDECLARED_ACTIONS` valve was **removed before 17 + ships**; the refusal has no opt-out. +- **ADR-0104 D2 action params strict by default** (#3438) — the + `OS_ACTION_PARAMS_STRICT_ENABLED` opt-in era ends; + `OS_ALLOW_LAX_ACTION_PARAMS=1` is the escape hatch — plus the D1 evidence + gates (`os migrate value-shapes`, fresh-datastore attestation, released-file + collection behind the verified file migration). +- **ADR-0113** — `required` becomes the write contract; `storage.notNull` owns + the column. +- **ADR-0114** — the closed field-error catalog and the `fieldErrors` → + `fields` rename (#3977). +- **The #4001 unknown-key strictness campaign's later clicks** — permission + sets + flows, RLS / sharing rules / positions, approval configs, hooks + + datasources, and the app shell & navigation tree, all `.strict()` with + self-fixing errors. +- **The seven protocol-17 retirements** — `api.requireAuth` (#3963), the wait + `timeoutMs`/`onTimeout` pair (#4158), `query.joins`/`windowFunctions` + (#4286), the `fields[]` object form (#4196), `query.cursor` / `distinct` + (#4286), `BatchOptions.validateOnly` (#4052) — and #4350's correction + re-labelling them protocol **17** (their tombstones said "18", so + `os migrate meta` was stepping over the two stack conversions and the + generated upgrade guide omitted all seven). +- **The #4212 retirement family** — the plugin lifecycle hooks, the typed-event + cluster, `ObjectQLEngine.use()`, the Dev Mode Plugin Protocol (#4149). +- **The #3896 security sweep's enforcement half** — criteria-less sharing + rules share nothing, RLS `enabled` is enforced, RLS `priority` and the four + inert tool keys are removed. +- **Flow `errorHandling.maxRetries` has one default** (#4247), **per-run flow + summaries** (#4354), and **file-backed SQLite in WAL mode** (#3941). + +What follows is the rest of the window: first the metadata- and +protocol-facing changes an upgrading application developer must read, then the +platform capabilities an administrator gains, then the Console delta. + +#### Breaking / behavior — protocol & wire + +- **The actions route speaks HTTP (#3962, #3951, #3937, #3913).** The + accidental 200-with-inner-envelope double wrap is gone; the contract is now + identical to `/data`: ran-and-returned = **200** `{success: true, data: + }`, single wrap; ran-and-rejected (business rule / + validation) = **400** with `error.details.code: 'VALIDATION_FAILED' | + 'FLOW_FAILED'` and per-field `fields[]`; not-dispatched = a real 404/403/503 + (an unknown action used to come back `{"success":true,"data":{"success": + false}}`); crashed (`TypeError`, driver class, sandbox timeout) = **500**, + visible to gateway error rates and APM instead of arriving as a green 200. + Five defects traced to the extra layer, including the Console's green toast + on failed actions and `redirectUrl` never firing. Object-less actions + canonically key on `'global'` and the `POST /actions//:action` shape is + mounted. `client.actions.*` callers need no change — the SDK still never + throws, and folds every failure into `{success: false, error}`. +- **`POST /actions/:object/:action` dispatches on the declared `type` + (#3915).** A `flow` action executes via the automation service **as the + caller** — `userId` / positions / permissions / tenant forwarded, so a + `runAs: 'user'` flow enforces RLS as the invoker instead of the user-less + UNSCOPED path — and the params bag is seeded with `recordId` (+ the declared + `recordIdParam`); `url`/`modal`/`form`/`api` actions answer 400 naming what + to call instead of a registry miss. Standalone `defineAction` artifacts and + Studio-authored action rows now resolve on REST with their + `requiredPermissions` enforced (previously MCP-only). Script action bodies + run with a bound, attributed `ctx.api` — owner-scoped writes stop dying + `FORBIDDEN`, and body writes are stamped with the caller and tenant and + join the open transaction. +- **A list query either applies or fails (#4121, #4134, #4164, #4181, #4226, + #4254, #4256).** Every input of the list/read surface now either takes + effect or answers 400 — nothing is silently dropped: a `?filter=` that does + not parse (or parses to a non-filter) is `400 INVALID_FILTER` — it used to + return the **unfiltered** table as a clean 200; unknown fields in `sort` / + `select` / `expand` / `searchFields` / `groupBy` / `aggregations` are + `400 INVALID_SORT` / `INVALID_FIELD` (a typo'd `select` returned **every** + column against FLS and data minimisation; an unknown `groupBy` collapsed N + groups into one; `sum()` folded to `0` — indistinguishable from a real + zero in a report); an unknown bare query parameter is `400 INVALID_FIELD` + with the canonical suggestion (`?pageSize=5` used to become a zero-matching + filter answering `total: 0`); a dotted-path sort (`?sort=account.name`) is + rejected with the denormalisation prescription; explicit `filter` + bare + field parameters compose with `$and` instead of dropping the implicit half; + and two sort spellings that silently never applied — the SDK's declared + `orderBy: string[]` and the `{field: direction}` map — now actually sort. + Export honours the searched view (`search=` / `searchFields=`, #4181). +- **Unsorted paged reads are deterministic (#4363, #3821).** A `limit`/`offset` + read with no `orderBy` — the shape every list view without a configured sort + sends — is now ordered by the unique key on SQL and MongoDB, and any + non-empty `orderBy` gains a unique tie-breaker, so page 2 can no longer + repeat a row while another row is never served. **The background walks seek + instead of counting** for the same reason: seven scans paged with a growing + `offset` while writing to the rows they were reading, so rows slid past the + cursor unvisited — which meant `rebuildApproverIndex` **deleted** index rows + for requests it skipped (an approver silently dropped from someone's queue), + `verifyFileReferences` reported referenced files as unreferenced, the file + and pinyin backfills left rows unconverted under a run that reported + success, and `scanValueShapes` — which opens the value-shape migration gate + on its evidence — could vouch for rows it never read. +- **`SqlDriver.findOne(object, id)` is removed.** An undeclared bare-id branch + that was on no contract, that nothing outside the driver's own tests used, + and that the other two drivers answered differently. Pass a query. In the + same pass `bypassTenantAudit` becomes a **declared** `DriverOptions` member + (it was live but read through a cast, so no caller was type-checked), with + its limit stated: it silences a diagnostic and must not change which rows a + write touches. +- **Query options fold once, everywhere (#3795, #4346, #4371).** The five RPC + aliases (`filter`→`where`, `select`→`fields`, `sort`→`orderBy`, + `skip`→`offset`, `populate`→`expand`) resolve through one spec-owned fold + with one precedence — four of five pairs were inverted between readers, so + `?select=a&fields=b` answered `[a]` on one path and `[b]` on the other. The + fold now covers **every** engine method: `findOne` / `count` / `update` / + `delete` / `aggregate` with `{filter}` used to match **every row** — + `update(data, {filter, multi: true})` rewrote the whole table and + `delete({filter, multi: true})` emptied it. Conflicting spellings + (`{where: X, filter: Y}`, `{top: 1, limit: 3}`) are refused naming both; + direct engine callers passing wire-only spellings or unknown option keys now + get an error naming the canonical key instead of silence (#4371) — which + also fixed queue purge, which passed a key the engine never read and so + deleted nothing. +- **`query.having` is enforced (#4286 follow-up).** Declared since AST v2 and + executed by nothing, `having` now filters aggregated rows on both the + native-driver path and the in-memory fallback, with `$and`/`$or`/`$not` and + loud rejection of unknown operators. A query that carried it was silently + returning every group. +- **Request bodies validate at the entry (#3899, #3878).** Catalog-declared + `requestSchema`s are enforced with `400 VALIDATION_FAILED` + `fields[]`: a + garbage `POST /data/:object/query` body used to degrade into an unfiltered + full read, `POST /automation/:name/toggle {"enable": false}` used to + **enable** the flow and answer 200, and a misnamed `POST /notifications/read` + key was a 200 no-op. `/analytics/query` and `/analytics/sql` validate the + **bare `AnalyticsQuery`** at the entry — the `{cube, query, format}` + envelope dialect of the retired shim is tombstoned, and the filter field is + `where`, not `filters` — instead of dying downstream as a 500 SQL syntax + error. +- **Bulk writes are bounded and bound to the path (#3939, #3933, #3897, + #3946).** The declared 200-row batch cap is now real on all five bulk routes + (`400 BATCH_TOO_LARGE`, governed by `batch.maxBatchSize`, 1..1000); a body + `object` can no longer move a bulk write or a `/data/:object/query` read to + a different object than the gated URL object; `deleteMany` builds its + predicate from the validated id list only — caller `options.where` used to + widen a one-id request into deleting everything the caller could see — and + per-id deletes now run `deleteBehavior` cascades and RLS/FLS under the + caller (its response becomes the structured `BatchUpdateResponse`); and a + caller-supplied `context` is dropped at ingress on these paths (#3960 — it + could previously become the execution context, `{isSystem: true}` included, + where no server context resolved). +- **Response envelopes converge (#3843, #3983, #4038, #4053, #4224).** The + settings, datasource-admin, external-datasource, package and share-link + route modules emit the declared envelope — payload under `data`, errors as + structured `{code, message}` on the ADR-0112 ledger — which un-broke two + shipped SDK methods (`client.shareLinks.create()` / `.list()`); the + dispatcher's duplicate top-level payload keys are gone; `GET /ai/agents` + (the last unenveloped SDK route) conforms; `/api/settings/*` error extras + move under `error.details`, with `SETTINGS_VALIDATION.fields` becoming + ADR-0114 `FieldError[]`. Raw-`fetch` callers of these routes add one `.data` + hop and read `error.code`; SDK callers are shielded — and the client now + normalizes both server envelope families, so `err.code` is always the + semantic string, never the HTTP status number (#3918). +- **Field validation speaks the caller's language (#3957).** Built-in + field-validation messages render in the request's locale (en, zh-CN, ja-JP, + es-ES — `Accept-Language` / `?locale`, falling back to the workspace + locale), name the field by its translated label, and carry a structured + `constraint` (`min`/`max`/`actual`/…). Match on `code` and `constraint`, + not English message text; any built-in is overridable per deployment via + `validation.field.` translation keys. +- **An absent capability answers honestly (#4093, #4113, #3891, #4087, + ADR-0115).** The last fabricating fallbacks are deleted. A mounted domain + with no implementation answers **501 naming the package to install** + (`/automation`, `/notifications`, `/ui/*`, `/ai/*` — previously misleading + 404/503s that sent operators chasing phantom routing bugs). `/auth/*` + without an auth service answers 501 instead of a **200 carrying a fabricated + session for any email and any password** — the mock shipped in production + `@objectstack/runtime` and told clients the one thing a server must never + lie about. The degraded analytics shim — which dropped the caller's + ExecutionContext, so authenticated callers got **aggregates over rows RLS + would hide**, and read a non-contract `filters` key — is removed: without + `@objectstack/service-analytics` the routes are not mounted (404), and + discovery reports `analytics: unavailable` instead of hardcoding it always-on + (#3989, #4010). The dispatcher's dead `/storage` bridge (which could never + complete a request, and whose wildcard shadowed the real presigned + `service-storage` routes) is removed. DevPlugin's stub table is retired — + an empty slot in dev behaves exactly as in production, and the allow-all + permissions / no-row-filter RLS / unmasked-field dev fakes now **fail + closed** like production does. Discovery computes every slot's verdict from + the registered service's own `__serviceInfo` (the `_dev: true` marker is + retired), its remedy strings name real packages (ten of fifteen previously + named packages that do not exist), and a slot self-declaring + `handlerReady: false` is answered like an empty slot instead of having its + fabricated 200 served (#4058, #4089, #4130). +- **`HonoServerPlugin` is a transport adapter (#4073).** Its raw data-C+R + surface, its third (pre-`DiscoverySchema`) discovery payload and the + `registerStandardEndpoints` flag are deleted — data and discovery each have + exactly one owner (`@objectstack/rest`; the runtime dispatcher). Every + composed host (`os serve`, `objectstack dev`, cloud) already answered + byte-identically. The three current-user endpoints (`/auth/me/permissions`, + `/auth/me/localization`, `/me/apps`) register unconditionally — no longer + hostage to the flag — yield correctly around the auth wildcard in either + registration order (#4088, #4117), and resolve per request through the + multi-tenant `kernel-resolver` seam, so authenticated tenant callers on a + routing host stop being told `{authenticated: false}` (cloud#927). +- **The memory driver is pure in-memory again (#4065, #4083).** A bare + `new InMemoryDriver()` — and a declared `driver: 'memory'` datasource — + silently wrote `.objectstack/data/memory-driver.json` into the working + directory and reloaded it on the next boot; the `persistence: 'auto'` + default drifted from the accepted opt-in design (#815) and is now `false`. + Durability is explicit (`{ persistence: 'file' }`, per-datasource + destinations), and the driver's "production-ready" claim is trimmed: it + stores no constraints — use in-memory SQLite when constraints matter. + `os init` scaffolds stop naming `driver-memory` as a dependency. +- **The `artifact-api` metadata source is removed (#4246).** Zero consumers + existed and half its URL contract had been dead since v5.0. + `{ mode: 'local-file', path }` is the single artifact source (it accepts the + public/commit-pinned cloud URLs); a still-configured `artifact-api` throws + loudly at `start()` instead of silently falling through to filesystem + scanning. +- **Smaller wire corrections:** the plural `/meta/:type` spellings no longer + skip the book-audience / app-RBAC / dashboard gates the singular spellings + enforced (#3984); `book.audience: 'public'` genuinely serves anonymously on + secure deployments (#3963 step 2) — review books that declare it, they are + now really public; three orphan operator vocabularies with zero importers + leave the spec (objectui#2945 Track A); `DriverPlugin`'s inert + `{datasourceName, registerAsDefault}` options are retired (#4320); the + legacy `_dev: true` service marker is retired in favour of `__serviceInfo`; + and exported spec types stop resolving to `any` (`NavigationItem`, + `FormField`, `QueryAST['fields']`, the `z.input` sides of the recursive + schemas) — authoring code whose invalid shapes previously compiled clean + now fails `tsc` with the real error, gated by the new `check:exported-any` + (#4171, #4195, #4221). + +#### Breaking / behavior — metadata authoring & runtime + +- **Flow-node config is enforced end to end (#4277, #4045, #4027, #4347, + #4389).** The twelve contract-carrying builtins `parse()` their `config` + before executing — a violation refuses the node as a guard naming every + violated path; `registerFlow` **rejects** undeclared config keys (exact + path, declared key set, did-you-mean, per-key tombstones like + `screen.visibleIf` → `visibleWhen`) and warns on keys a `configSchema` does + not declare; declared bare-CEL slots are validated + (`screen.fields[].visibleWhen` authored as `'{var} == true'` shipped a + forever-paused run); and ADR-0031 regions (`loop.body`, + `parallel.branches[]`, `try_catch.try/catch`) are walked by conversions, + validators and lint alike, so nested nodes stop escaping every check. A + legacy `{var}` condition carrying an unresolved dotted reference now + refuses instead of comparing strings — `'oppRecord.amount > 500000'` was + constantly true: a gate that never gated. +- **Flow-node aliases graduate to conversions (ADR-0087 D2).** `subflow` and + `map` `config.flow` → `flowName` (#4278, #4045), `notify`'s nested + `source: {object, id}` → `sourceObject`/`sourceId`, and `connector_action`'s + mis-rooted config lifts onto `connectorConfig` — Studio-authored connector + nodes never dispatched; stored flows are healed at load. All protocol-17 + windows applied by `os migrate meta` and at every rehydration seam, + alongside the seven #3796 aliases documented above. +- **A blank hook target is refused.** `object: ''` / `[]` silently registered + the hook on **every object in the tenant**; a wildcard must now be the + visible `'*'`, and `bindHooksToEngine`'s `strict` option actually fails + fast. `defineHook()` lands so convention-scanned hook files get the same + parse-at-import treatment as the rest of the define family (#4269). +- **Stored metadata replays the conversion chain (#3903).** Studio- and + API-written rows at rest go through the same ADR-0087 machinery as authored + source at every read seam (including `registerFlow` rehydration) — a stored + action with the removed `execute` dispatches via `target` again; boot + hydration validates each row post-conversion and diagnoses invalid ones + with `[metadata_spec_invalid]` instead of shrugging. **The rows themselves + can now be brought forward too (#4327):** `os migrate meta --stored` + replays the chain over `sys_metadata` and rewrites what still carries a + pre-protocol shape, through the normal write path. Optional — the read path + is the guarantee either way — but it is what makes the conversion pass a + no-op on your data instead of a permanent shim. +- **Studio's metadata forms tell the truth (#3786).** Four of seventeen forms + had drifted from their schemas, so controls saved nothing: the Object → + Capabilities toggles bound a key the schema does not declare (all seven + dead — Track history, Searchable, API enabled, Files, Feeds, Activities, + Clone), and the Fields grid offered sixteen undeclared keys (PII, Encrypted, + Indexed, …) while missing the canonical spellings for five renames. Forms + are reconciled and gated registry-wide; re-author anything configured + through the dead controls. +- **Authored view filters compile (#3948, #4029).** Eight canonical + `VIEW_FILTER_OPERATORS` members (`equals`, `before`, `after`, …) that + validation accepted but the AST refused now lower to real queries on every + driver, and an uncompilable filter element **throws** instead of matching + every row. `saveMeta` persists canonical operator spellings, so the ~30-entry + legacy alias bridge stops growing and can eventually retire + (objectui#2945). +- **Analytics stops guessing (#4157, #4128, #4033).** An undeclared measure or + an out-of-vocabulary measure `type` **errors** instead of silently answering + `COUNT(*)` (revenue charts that were quietly row counts now say so); the + silently-dropped filter family — `$between`, `$null`, `$startsWith` / + `$endsWith`, inverted `$exists: false`, `$notContains`, and the `$or`/`$not` + combinators — now actually filters, so dashboards stop drawing every row; + time-bucketed queries project their bucket column (a trend chart got N + values and no x-axis). Time-typed dimensions gain chronological default + order, and `ReportSchema.order` becomes a declarable ordering lowered into + the dataset selection (#3916). +- **Temporal correctness lands as one campaign (ADR-0053).** `Field.datetime` + and `Field.time` get one canonical storage form per backend — mixed + epoch/ISO storage made a two-sided date window return **zero rows** on + SQLite while matches existed, and MySQL moves to `DATETIME(3)` with + UTC-pinned connections (REST datetime writes on MySQL previously failed + outright); legacy columns converge at schema sync and `os migrate plan` + lists the in-place work with row counts (#3912, #3994, #4047, #3954). A + bare `YYYY-MM-DD` upper bound covers its whole calendar day across SQL / + memory / MongoDB / the RLS write-side evaluator — dashboards lost the final + day's rows after midnight, and a `{ $lte: '{today}' }` RLS `check` policy + denied every write made after 00:00 (#3777, #4042). The type-blind + evaluators compare a JS `Date` against wire text correctly — fail-closed + RLS was denying legitimate SDK writes whose payload carried `new Date()` + (#4191). `date` `NOW()` defaults resolve UTC on every dialect (#4022). A + shared conformance matrix in `@objectstack/spec/data` is asserted by five + backends so none of these classes can silently re-drift. +- **Sharing authority is real authority (ADR-0111, #3902).** The + share-management surface authenticated the caller and then ran under + `SYSTEM_CTX` — **any signed-in user could revoke anyone's share, enumerate + who-sees-what, write self-grants, and define org-wide sharing rules**. Now: + `canManageShares` (system, record owner, Modify All Data, or hierarchy + write-scope depth) is enforced in the service on every + `/data/:object/:id/shares` verb; `listShares` is management-gated and the + open `sys_record_share` read surface is self-scoped for non-admins; the + whole `/sharing/rules` surface requires the new **`manage_sharing`** + capability (seeded into `admin_full_access`); an `edit`-level share no + longer confers **delete** (`canDelete` = ownership, write depth, or + `modifyAllRecords`; bulk deletes stop widening through shares); revoke + validates the share belongs to the URL's record; grants on objects the + sharing gates never consult fail `SHARING_NOT_ENABLED` instead of sitting + inert; and a record's owner or a Modify-All admin — not just the minter — + can revoke a share link. + +#### Security corrections + +Beyond the sharing-authority work and the #3896 enforcement half documented +above, four fail-opens were found and closed in this window — all ship in +rc.1, and administrators should know what changed: + +- **The project-membership gate never ran (#4127 sweep).** + `enforceProjectMembership` probed the auth service through a shape it never + had, so `userId` stayed unset and a **signed-in non-member passed the + gate** on every deployment with project scoping on. Found by the new typed + service-slot lint; the lint stays on to keep the class closed. +- **Analytics answered without a caller (#3891).** The degraded shim served + RLS-free, tenant-unscoped aggregates (removed — see above). +- **Dev stubs inverted their decisions (ADR-0115, #4093).** With + `plugin-security` absent, dev filled the slots with allow-all / no-RLS / + unmasked fakes behind one warn line; the slots now stay empty and fail + closed, and `plugin-dev` refuses `NODE_ENV=production` outright + (`OS_ALLOW_DEV_PLUGIN=1` overrides, and now brands the boot loudly instead + of starting silent, #3900). +- **Imported users skipped field coercion (#4251 sweep).** The + `/admin/import-users` route probed a method its service never had, so rows + reached `sys_user` uncoerced on every deployment. The same typed-slot + campaign fixed `handleAuth` reaching the (now deleted) mock instead of the + real auth service, and `/analytics/sql` crashing 500 on providers without + `generateSql`. + +#### New capabilities (backend) + +- **Approvals grow up as a decision surface.** A `decisionOutputs` entry can + declare `required: true` — an approve carrying a blank routing value is + refused before any write, instead of the next node faulting or the run + stalling (objectui#2955); a reassign writes structured `reassign_from` / + `reassign_to` parties that timelines render without free-text archaeology + (#4365); and a `runAs: 'system'` flow's writes are audited as + `svc:flow:` instead of "Unknown user" (#4366). That label is a + fallback, not a replacement for the operator — it stands in only for a run + that resolves no user at all (a schedule, or a system flow fired by a write + that itself carried no user). Where the trigger does resolve a user, #5494 + later on this page carries that user through unchanged, so the audit row + still names the human. +- **Run summaries learn honesty about side effects (#4395, #4396).** + `connector_action` and mutating `http` nodes report `unmeasuredEffect` (the + platform cannot know whether `crm.push_opportunity` writes), an + `unmeasured_count` column joins `sys_automation_run`, and the broken-sweep + detector becomes `selected > 0 AND acted = 0 AND unmeasured = 0` — without + the third clause it would fire on every healthy connector-driven flow. +- **Scheduled jobs actually fire.** Cron jobs registered before + `kernel:ready` landed on a placeholder adapter that silently ignores `cron` + schedules — in the default configuration a business plugin's cron jobs + never ran. Early registrations now migrate onto the DB-backed adapter, so + operators will observe scheduled work starting to happen. +- **`os migrate` keeps its promises (#3917, #3924, #3954, #3955, #3978).** + Zero database writes before the confirmation prompt (boot-time DDL is + deferred and rendered in the plan); a busy SQLite database is detected — + file-descriptor probe naming the holding pid, which works on the default + rollback-journal mode — and refused without `--force`; the plan lists + in-place datetime/time normalisation with row counts so large rewrites can + be scheduled into maintenance windows; zh-locale deployments stop being told + to drop their live pinyin `__search` companion columns as "destructive + orphans"; and virtual formula fields stop appearing as forever-pending work. +- **Boot tells operators the truth (#4012, #4085, #4095, #4110, #4096, #4167, + #4002).** Plugin boot-phase warnings survive the startup banner (previously + discarded wholesale — including the ADR-0110 action-governance inventory); + `os serve ` boots a fresh project with no compiled artifact instead + of dying with a misleading error, and grafts the config module's + `onEnable`/`functions` onto artifact boots so declared `script` actions stop + 404ing; a **named** artifact path that does not exist fails the boot naming + the path — it used to print "Server is ready" over an empty platform; + `OS_STORAGE_ROOT` actually takes effect (uploads land under + `.objectstack/data/uploads` from the first byte) and the spurious every-boot + "storage adapter swapped" warning fires only on a real move; `stack.storage` + — never a declared key, silently ignored — is linted with the two working + channels named (`OS_STORAGE_*`, Setup → Settings); and authored `api.*` + keys survive the serve-time config merge instead of being replaced + wholesale. +- **Platform infrastructure is composable (#4243, #4270, ADR-0116).** + `sys_migration` and `sys_secret` move to `PlatformObjectsPlugin`, so a + kernel without storage or settings keeps its migration ledger and its + encrypted-secret store — `Field.secret()` writes previously threw on + settings-less kernels. The kernel `Plugin` contract gains declared ordering + (`optionalDependencies`, `requiresServices`, `providesServices`) with + boot-time validation that names both plugins, both slots, and the fix; + `PLATFORM_ALWAYS_ON_CAPABILITIES` is a published export, so hosts stop + hand-mirroring the always-on slate (cloud's mirror had silently lost `sms`, + `messaging` and `analytics`). Graceful shutdown disconnects datasource + pools through the one datasource path, and adopted pools are never closed + by a kernel that does not own them (#3993). +- **Seeds load what you wrote (#3911, #3932).** Natural-key **arrays** resolve + for `multiple: true` lookups (previously dropped with a warn), and dropped + unusable references are counted and named in the boot banner + (`showcase 42 ok / 3 lost links`) instead of the load reading clean. +- **Authoring lints gather the evidence (#3786, #4120, #4271, #4254, #3991).** + The advisory `lintUnknownAuthoringKeys` reports every silently-stripped + object/field key across all sixteen metadata collections — with + rename/retirement guidance and nested-metadata descent — through + `defineStack`, `os validate` and `os compile`; error-severity + reference-integrity rules gate flow `update_record` / `create_record` + writes to undeclared fields and stale `searchableFields` declarations; + hook and action bodies get the same write-set analysis as advisories, plus + a runtime report for dead `ctx.record` writes (#4345); and a field carrying + both per-tenant `unique: true` and a global unique index is flagged before + the second tenant finds out. All three commands now answer identically by + construction — `os lint` and `os compile` were letting through react pages + and readonly flow writes that `os validate` rejected, including the field + resolution that **gates** (an unresolvable `` predicate + yields a silently empty list, indistinguishable from no data). Expect CI + running `os lint` to newly fail on metadata that was already broken. +- **Service slots are typed contracts (#4251, #4127).** `IObjectQLEngine` + lands (the seven consumer-local stand-in surfaces die), `getService` + resolves through a slot→contract ledger, and a shrinking baseline bans + `any`-typed lookups. This campaign is what surfaced the membership + fail-open, the import-coercion miss and the cron-adapter migration above — + the ratchet keeps the class closed. +- **Declared UI slots become authorable.** `FormSection.pane` places sections + in split forms explicitly (objectui#2153), and `element:button`'s + `properties.action` gains `InlineActionSchema` — previously authorable only + via `as any` (objectui#2997). +- **Package hygiene reaches consumers (#4261, #4248, #4097).** `CHANGELOG.md` + ships in every tarball again — 68 of 69 packages had silently severed the + delivery path for exactly the FROM→TO migration notes an upgrading agent + greps after a tombstone error; twenty packages stop shipping raw sources + and tests to npm; `os init` stamps `engines: { protocol: '^17' }` so new + packages join the ADR-0087 load-time handshake; and `spec-changes.json`, + the generated upgrade guide and the `spec_changes` MCP tool actually + report the 16 → 17 chain (they still said 16.0.0). + +#### New in Console — bundled objectui advanced `4a4829d0ef39 → 7d9734d5e321` + +143 objectui commits across five pin moves, released as **objectui 17.1.0**. +(The pin changesets enumerate 79 of them; one range under-enumerated its own +window and is recorded by `console-bebaebd39ace-backfill` — the fixes it +covers are folded into the list below rather than left to the changelog.) + +**How the Console pin is recorded on this page.** `scripts/bump-objectui.sh` +advances `.objectui-sha` whenever the bundled Console moves, and every move +writes a `.changeset/console-.md` that enumerates its own range. Each +move is reported under the *Landed since* window that shipped it — never +folded into an earlier window's range — so the chain reads end to end: +`4a4829d0ef39 → 7d9734d5e321` here (released as `rc.1`), `→ f5bc4c78be76` +under *rc.1* (`rc.2`), `→ f995a452d2ca` under *rc.2* (`rc.4`), +`→ 7dfbeb704e1e` under *rc.4* (`rc.5`), and `→ 0cf8f0f70d10` under *rc.5* +(`rc.6`). The two author-facing breaking migrations the bundle +carries (objectui#3233, objectui#3172) arrived with `f995a452d2ca` and are +documented under *Breaking changes & migration* above. + +- **The lockstep half of ADR-0110 — release-critical.** The rc.0 pin predates + the client fix, so the Console it built still posted `action.target` to + `/api/v1/actions/:object/:action`. Against a 17 server — which resolves the + declaration by `name` and refuses an unresolvable one — **every + target-bound script action would 404 from the shipped Console**. The + ADR-0066 D4 capability gate is now applied on every action surface, and a + modal action is client-side only (its server fall-through is dropped). +- **Action feedback stops lying.** A failed server action no longer shows the + green success toast, `redirectUrl` finally fires, one source now owns the + `/actions` envelope rule, and the Console reads the single-wrapped + responses the server started sending (#3962). A server rejection that names + fields marks **those** fields in the form; error-code branches survive the + ADR-0112 rename; a 400 from the server no longer reads to the user as + "check your connection"; and settings validation errors render against the + fields that caused them. +- **Filters, sorting and export agree with the server.** Every spec view + operator is bridged onto the filter AST (the client half of the operator + parity above); a view's own filter no longer vanishes when the user adds + one, nor arrives as a predicate on columns that do not exist, nor depends + on whether the query expands a lookup; a column-header sort orders the + whole list rather than the visible page, and a string `$orderby` reaches + the server as a sort instead of a list of character indices; sorting a + lookup column stops ordering by an invisible key; exporting a searched list + downloads the searched rows, not the unsearched superset; and every column + resolver reads one identity key, resolved at ingestion and reported out + loud. +- **Forms stop losing what you typed.** A tabbed or sectioned modal keeps + **every** tab's values and a split form keeps **both** panels' — previously + only the active pane survived the save; a `defaultValues` change no longer + discards the field being filled (and new defaults apply in the commit that + renders them); leaving a page with unsaved input in a modal or drawer form + is guarded; swapping `recordId` no longer leaves the previous record on + screen; and a wizard that ends on a field-less review step can finish. +- **Edits are safer.** Form edit saves send `If-Match` and surface 409 + conflicts instead of silently overwriting; a wizard with `allowSkip` no + longer submits past skipped fields; a select no longer wipes itself when + its value outruns its options; numeric and boolean option values survive + selection typed; multi-value lookup is selectable in inline edit and + hydrates through a batched `$in` (showing loading rather than the empty + placeholder); tabbed and split forms honour the form view's own `columns`. +- **Spec values render instead of failing three different ways.** Three tiers + of spec-declared values were silently wrong, validated into nothing, or + red-boxed the surface; they now render. The spec→FilterBuilder operator + table covers the whole view vocabulary, a spec `series[].type` draws (and a + spec-shape `series` plots at all), a chart says so when its rows carry no + category key instead of drawing an empty axis, a missing analytics + capability stops rendering as an empty KPI, a legacy string row action runs + instead of green-toasting a no-op, and a flow or action that failed under + HTTP 200 stops reporting success. +- **Bulk actions become per-record.** An object-declared bulk action runs over + the selection, `visible` is evaluated per selected record, params render + through the shared form-field widgets (lookup errors get Retry, `sys_user` + params get the PeoplePicker), and a bulk delete clears the row checkboxes. + The `bulkEnabled` derivation is dropped — the spec key is a tombstone. +- **Approvals and notifications.** Decision outputs reach both decision + surfaces; a reassign hand-off reads legibly and `svc:*` audit actors render + as "System"; the Console mounts the notification surfaces, each spec + `displayType` gets its own presentation, and the spec `icon`, config, + position and action variant are read instead of forked or ignored; + Attachments become a peer tab with a live count badge, translated. +- **Studio and the designers.** A page button created in Studio can be given + an action (the `InlineActionSchema` above is its contract); the script + node's form authors what the executor actually runs; a published + `configSchema` can no longer delete a node's sibling-block editors; and the + flow inspector preserves sibling blocks. `SplitForm` honours the new + `FormSection.pane`. +- **Types stop forking the spec.** `Page`/`App`/`Dashboard` validate the + spec's own fields instead of passing them through; navigation metadata + stops losing spec fields the renderer already honours; the `*Validation` + five and five more spec-named symbols are derived rather than copied; and + the form-field boundary between spec and runtime is declared, with a + tripwire against re-importing the wrong one. The SDUI public contract is + curated and guarded against silent drift, with declared inputs for the + `page:*` / `element:*` / `record:*` block families. +- **Resilience and chrome.** A transient `/me/permissions` or + `/me/localization` failure retries instead of stranding the app on its + loading state; a 403 is no longer blamed on the network; ⌘K search is no + longer capped at 8 objects; the chart view gets a label and an icon in the + view switcher; and the developer-voiced default form subtitle is dropped. + +### Landed since 17.0.0-rc.1 + +These changes are on `main` after the `rc.1` cut and **roll into 17.0.0-rc.2**. +At the time of writing the window had left 209 changesets pending — 46 +`major`-class, 74 `minor`, 55 `patch`, and 34 that release nothing (CI, tooling +and docs); the cut itself released **166 changesets — 45 `major`-class, 69 +`minor`, 52 `patch`**, counted across the `17.0.0-rc.2` sections of the package +changelogs. The Console pin stood at `7d9734d5e321` when the window opened and +advanced twice inside it — to `785b8a5d432c`, already the value when the +paragraphs below were written, and then to `f5bc4c78be76`. The two moves are +enumerated in `console-785b8a5d432c` and `console-f5bc4c78be76`; neither +carries an author-facing breaking migration, and the two that exist arrive in +the next window. + +Two campaigns dominate the window, and both are *finishing* rather than +starting: the #4535 dual-source convergence and #4001's close of the authorable +surface. Around them, the ADR-0049 enforce-or-remove sweep reaches the driver +and datasource contracts, ADR-0078 grows a completeness gate plus a runtime +twin, ADR-0119 makes multi-write atomicity real and migrations crash-safe, and a +run of security and fail-loud corrections lands. + +As in the rc.0 window, landings that belong to a section above are documented +**there** rather than repeated here: + +- **`findOne` must say which record it wants** (#4419) — the missing-predicate + read that returned an arbitrary first row. +- **The `workflow` service slot, `datasource.readReplicas`, the two connector + clusters and the `kernel` metadata-loader envelope family** — all in the dead + spec clusters table (#4451, #4468, #4480, #4499, #4411). +- **`MetadataWatchEvent.type` narrows to the three values the runtime emits** + (#4536), and **`MetadataExportOptions` / `MetadataImportOptions` move to + `./contracts`** (#4538) — both in the upgrade checklist. +- **`os migrate meta --stored`** (#4327) — in the checklist as the optional + stored-metadata pass. + +#### One name, one declaration — the #4535 dual-source campaign closes + +A bare name exported from two `@objectstack/spec` subpaths, resolving to **two +different declarations**, is the #4411 trap: an editor's auto-import picks by +name, the shapes overlap enough to compile, and the mistake surfaces far from +its cause — an `undefined` at an edge value, or (when neither side is +`.strict()`) every foreign key silently stripped. Seventeen clusters are closed +here, each judged individually against an import-statement-level scan of all +three repositories (framework, cloud, objectui): the declaration that actually +flows at runtime keeps the bare name. + +| Cluster | Name(s) | Resolution | +|---|---|---| +| C1 (#4572) | `WebhookConfig` / `WebhookEvent` | removed from `./api` — the bare names are `./integration`'s alone; `./api`'s OpenAPI webhook *descriptor* is renamed `OpenApiWebhookEvent` | +| C2 (#4587) | `MetadataEvent` / `MetadataBulkRegisterRequest` | removed from `./kernel` (a lifecycle envelope nothing emitted); `./api` owns them | +| C3 (#4610) | `Notification` / `NotificationConfig` | removed from `./ui` (a toast shape objectui never adopted) and `./system`; `./api` owns them | +| C4 (#4641) | `Session` | `./identity`'s declaration removed — the two did not even agree on field names, so a wrong pick failed as a runtime `undefined`; `./api` keeps the names | +| C5 (#4653) | `ActivationEventSchema` | converged on `./kernel`'s **structured** shape (`./studio`'s `z.string()` validated nothing); `./studio` re-exported it — then #4657 retired the whole vocabulary, see below | +| C6 (#4658) | `EventSchema` | `./automation`'s orphan state-machine *signal* declaration deleted (zero key overlap with the event-bus envelope); `./kernel` is the single source | +| C7 (#4741) | `PackageDependency` | `./kernel` renamed `ResolvedPackageDependencySchema` (the resolver's edge); `./cloud`'s manifest declaration keeps the bare name | +| C8 (#4661) | `RetryPolicy` | one declaration carrying the union of both; `./automation` and `./system` re-export it | +| C9 (#4684) | `RateLimitConfig` | `./integration` renamed `ConnectorRateLimitConfig` — the two limit **opposite directions** of traffic, and spell the window `windowSeconds` vs `windowMs` | +| C10 (#4740) | `EnvironmentArtifact` | single declaration on `./system` holding the **live wire** shape, re-exported by `./cloud`; the never-implemented v0 family (`functions` / `manifest` / `payloadRef` + 8 sub-schemas) removed | +| C11 (#4688) | `HttpRequest` | `./shared` holds the one declaration | +| C12 (#4703) | `FieldMapping` | **three** declarations → `ConnectorFieldMapping` and `ImportFieldMapping`; `./shared` keeps the base | +| C13 + C15 (#4738) | `DataSyncConfig` / `ConflictResolution` | the automation-side L1 "Simple Sync" layer (17 exports, zero consumers) deleted; `./integration`'s enum renamed `ConnectorConflictResolution`; `./ui`'s offline-sync vocabulary keeps the bare name | +| C14 (#4691) | `HttpMethod` | `./ui`'s 5-value subset renamed `HttpMethodType`; the value sets differed, not just the types | +| C16 (#4739) | `TenantPlan` + the tenant-provisioning family | `./system`'s never-implemented provisioning protocol deleted (including `IProvisioningService` / `ITenantRouter` / `ResolvedTenantContext`); `./cloud` untouched | +| C17 (#4737) | `ActionLocationSchema` | `./studio` renamed `ActionContributionLocationSchema` (3 Studio-shell values); `./ui`'s 7-value app-UI vocabulary keeps the bare name | + +Three more convergences land alongside them: the dual-source `MetadataFormat` / +`CacheStrategy` enums, which had diverged on their **values** (#4537); the +eleven `./contracts`-vs-domain parameter/result names (#4538); and the three +cross-form names, where the two sides did not share a *form* — `ShareRecipientType` +→ `RecordShareRecipientType`, plus `TransformType` and `suggestFieldType` (#4539). + +**None of this is authorable metadata**, so there is no `os migrate meta` step +and no tombstone: these are type and schema *exports*, and every break is a +compile-time `TS2305` naming the symbol. Fix it by changing an import path or a +name. The campaign leaves a gate behind — `dual-source-exports.baseline.json` is +now ratcheted by **symbol identity**, not by name (#4446), so two entries +exporting the same name from different declarations fail the build. + +#### The authorable surface closes — #4001 reaches zero + +The unknown-key strictness campaign began with a single object schema and ends +in this window. Every authorable type now rejects unknown keys with a +prescription that names the offending key and its canonical spelling: + +- **`ObjectSchema` closes on the PARSE path**, not only in `create()` (#4522) — + the founding example of #4001, which had been live only at one of its two doors. +- **`field`** (#4531), reusing the curated table that already recorded which + advice would have been wrong. +- **`dashboard`** header, filter bar and root (#4532). +- **`action`** (#4533), taking the ADR-0010 protection-envelope debt list to zero. +- **`mapping` / `agent` / `page`** (#4530) — and `strictObject` stops suggesting + keys that were removed. +- **`translation`** bundles and items, at both doors (#4529), retiring #3778's + bespoke ten-key guard into the shared error message. +- **The six `validation` variants**, each against its own key set (#4527). +- **Six more registered types** — `report`, `dataset`, `email_template`, + `skill`, `job`, `book` (#4528); `skill`'s silently-stripped `permissions` + stops pretending to be a gate. +- **The Studio authoring surface** — plugin manifests, the flow builder, the + object designer (#4541). +- **The view surface** — the container, both view kinds and the ~28 config + shapes under them (#4534). This is the last batch. + +`strictObject` (#4514) is what made the tail cheap: closing a shape is one call, +with `seed` and `doc` converted first. One gate turned out to be hollow on the +way through — the protection-envelope invariant test silently skipped 24 of 25 +registered types; fixed, it immediately found 8 undeclared envelopes (#4519). +The deleted-baseline discipline tightened too: a removed `authorable-surface.json` +line must now prove itself rather than being taken on trust (#4650). + +#### ADR-0049 enforce-or-remove — the sweep reaches the driver and datasource contracts + +The v16 sweep took the *authoring* surface; this one takes the **contracts**. +Each removal below was declared, strict-guarded or `required`, and read by +nothing. + +| Removed | Why | +|---|---| +| 31 of 34 `DriverCapabilities` bits (#4634) | written by every driver, consulted by no engine, planner, REST layer or renderer. With zero readers the values went wrong unnoticed: `SqlDriver` declared `streaming: false` while implementing `findStream`; `InMemoryDriver` declared `streaming: true` over a full-table read — the exact inverse of the guarantee. Three bits have a real reader and stay | +| `IDataDriver.findStream` (#4484) | a **required** contract method with no caller anywhere, documented as the memory-safe large read — and its two main implementations did the opposite | +| `IDataEngine.batch?` (#4618, ADR-0119 D3) | declared for the life of the contract, implemented by nothing, called by no one. Use `engine.transaction(cb)`, `batchData` with `options.atomic`, or `POST {basePath}/batch` | +| `datasource.retryPolicy` (#4583 B) | four keys read by no connect or query path — nothing ever retried on them. Do **not** "fix" this by renaming: `hook.retryPolicy` / `job.retryPolicy` are enforced, spell the delay `backoffMs`, and are a different key on a different type | +| `datasource.healthCheck` (#4583 C) | no probe loop ever existed. Liveness is probed on demand via the driver handle's `ping()` / `checkHealth()` | +| `datasource.capabilities` (#4583) | eleven booleans, none read — pushdown is decided by the runtime driver's own `supports.*`. `readOnly` is why this is not tidy-up: it reads as a safety property, the shipped CRM example labelled a datasource a "Read Replica" on the strength of it, and the datasource accepted writes exactly like the primary | +| `external.label` / `external.requirePermission` (#4583 D) | a second display name that never displayed, and a permission no authorization check ever consulted | +| `RestServerConfig.openApi31` (#4579) | `normalizeConfig` forwarded five keys and silently discarded this one; the served `/openapi.json` never consulted the config | +| `activationEvents` (both keys) + `ActivationEventSchema` (#4657) | lazy plugin activation no runtime in four repositories ever implemented — every plugin has always activated on load | +| The standalone `validation` metadata kind (#4509, ADR-0088) | `ValidationRuleSchema` carries **no object-binding key**, and all six variants are `strictObject`, so an author could not supply one. A rule authored through that door parsed, saved, reported success and intercepted nothing — including a `state_machine` rule, so an author could believe they had locked down state transitions and have changed nothing | +| `job` runtime creation and org overrides (#4509) | `JobSchema.handler` names a function in the compiled bundle's function table, which a runtime writer cannot reach — so a job created in Studio or via `PUT /meta` saved and never ran. `allowRuntimeCreate` / `allowOrgOverride` are now `false`; `job` stays first-class through `*.job.ts` and `defineStack({ jobs })` | +| Six `authorWarn` dead keys (#4667) | `book`/`group` `translations`, `job.id`, `translation.validationMessages`, `app.homePageId`, `app.areas[].order` — each shaped so an author reasonably concludes it configures something | +| Five unwarnable keys (#4509) | mapping `extractQuery` / `errorPolicy` / `batchSize`, contextSelector `includeAll` / `placement`. Four carry schema **defaults**, and a default materialises at parse time — so the advisory lint could not tell an authored value from a supplied one, and removal was the only channel that reaches the author | +| `app.areas[].visible` / `.requiredPermissions` (#4651) | not inert — **fail-open**. See Security corrections | +| `sys_comment.visibility` / `.reply_count` (#4756) | `visibility` is a security-looking key with no gate behind it: a comment marked `private` was visible to exactly the same people as a `public` one. After #4630 there is no replacement key — access is the record's | +| `DataEventType 'data.field.changed'` (#4673) | no producer, and unimplementable against `DataEventSchema` as written — the payload is record-shaped, with no `field` / `oldValue` / `newValue` slot | +| The orphan notification-template vocabulary (#4616) | `EmailTemplate` / `SMSTemplate` / `PushNotification` / `InAppNotification` existed only as members of the union #4610 deleted. Use `EmailTemplateDefinition` and its siblings | +| `script` node `actionType` branches (#4343) | see below | + +Two entries in the same batch went the **other** way, because a bridge existed: +`doc.tags` is now declared, so a book group's `include: { tag }` can finally +match something, and declared email templates reach the mail service (#4509). +Closing a door and building a bridge are the same discipline applied to +different evidence. + +One rename rounds it out: **`managedBy: 'system'` becomes `'system-data'`** +(#3355). ADR-0103 split the overloaded bucket additively in v16, which left the +surviving value naming the half that had already moved out — `system` sitting on +precisely the objects a user writes. `system-data` states both boundaries: the +schema is the platform's, the data is the admin's or the user's. One deliberate +consequence — the affordance default flips from LOCKED to **WRITABLE**, so the +`userActions: { create, edit, delete }` blocks the eight platform objects used to +re-open their writes with are now redundant and deleted. Keep `userActions` only +to *narrow*. + +#### Author-time gates: a completeness test, and a fourth door + +**ADR-0078 completeness (#4544).** An instance can be Zod-valid, use only live +properties, and have a correctly-authored sibling that provably runs — and still +be dead, because it omits a config its consumer needs and the consumer silently +no-ops. The founding case: an AI authored `{ type: 'summary' }` with no +`summaryOperations`, the index builder skipped it, the field read `0` forever, +and every gate the author could see was green. This is worse than the unknown-key +hole #4001 closed: there the author wrote a key we don't know and the parse +rejects it; here every key is one we know and the author gets a **success**. + +The judgement now lives in `@objectstack/spec/kernel` (`checkFieldCompleteness` / +`checkViewCompleteness`) and is consumed by `@objectstack/lint`'s +`validate-functional-completeness`, so `os build` / `os validate` / `os lint`, +MCP and hand authoring are all covered rather than only cloud's AI-build +graph-lint. Every rule cites the runtime line that makes it true: + +| Rule | The silent skip | Severity | +|---|---|---| +| `field/summary-without-operations` | `engine.ts` — `if (!d.summaryOperations) continue` | error | +| `field/formula-without-expression` | the formula plan is built only from fields that have one | error | +| `field/relationship-without-reference` | `$expand` — `if (!referenceObject) continue` | error | +| `field/choice-without-options` (`select`, `radio`) | `record-validator.ts` — an empty option list disables server-side value validation | error | +| `field/choice-without-options` (`checkboxes`) | same branch, shared with free-form | warning | +| `view/layout-without-binding` (`kanban`, `calendar`, `gantt`) | the renderer falls back to literal default field names | warning | + +The deliberate **non**-rules are pinned as hard as the rules: `multiselect` +without options is blessed by `record-validator.ts` as a mode, so flagging it +would be another false prescription. Phase 3 adds the shapes whose verification +passes are done — a webhook with no `triggers` (#4565), an action nobody placed +(#4501), and nav targets that are not object names (`page` / `report` / +`dashboard`, ADR-0072, #4574). Phase 4 gives the field rules and the webhook +rule a **runtime twin** (#4599): `SchemaRegistry.registerObject` — the choke +point every metadata door converges on — emits the *same rule ids* at +registration, one aggregated line per object, and **warns without ever +throwing**. An inert field must not kill a boot that thousands of healthy +objects share. + +**The fourth door (#4463).** The 26 author-time rules `os validate` / `os build` / +`os lint` share ran on those three commands and nowhere else — but every runtime +metadata write (Studio's designer, REST `/meta` CRUD, an MCP agent authoring a +flow) reaches `saveMetaItem`, which did a Zod `safeParse` and stopped. For a +tenant, that was not the weakest of four doors, it was the **only** door: a +`sys_metadata` overlay row is not in the CLI's config file, so there was no +command they could run instead. An approval flow whose `expression` approver is +broken CEL is Zod-valid, so it saved, registered, and failed at the node's entry +the first time it fired. + +- `AUTHORING_RULES` moved from `packages/cli` into `@objectstack/lint`, with a + kernel-safe `@objectstack/lint/runtime` subpath that loads neither `typescript` + nor `sucrase`. There is one table, and a second cannot be introduced without + failing `authoring-rule-wiring.test.ts`. +- A `state: 'active'` `saveMetaItem` — and the draft→active promotion in + `publishMetaItem` — of a **flow** runs the flow / approval / expression / + reference rule families and refuses a gating finding with **422 + `INVALID_METADATA`**, carrying `rule` / `path` / `where` / `message` / `hint`. +- **Draft saves are never gated.** Only the write is judged: rules run twice + (with and without the submitted item) so a pre-existing violation in a stored + row never blocks an unrelated save. +- Escape hatch `OS_ALLOW_UNLINTED_METADATA_WRITES=1` turns the refusal into a + loud log for a migration window. Only `flow` writes are gated in this pass; + every other type carries a recorded reason in the registry. + +Alongside them: every author-time rule that *can* gate now runs on all three +commands (#4409); view `searchableFields` is validated at build time against the +same judgement the runtime applies, so a `lookup` typo no longer waits for a 400 +(#4830); `has(x)` is not a null guard, and publish rejects unguarded nullable +comparisons (#4763); `bulkActionDefs` gets a real shape and its aggregate name is +linted (#4457); `validateFormLayout` is wired into the registry; and the liveness +gate now governs **every** registered metadata type (#4487, #4488). + +New CI gates land with them — `check:adr-anchors` so governed code keeps naming +its decision, expanded to the full authz/security ADR set (#4575); +`check:init-service-contract` (#4471, ADR-0116); a startup-registry-verdict gate +(#4777); a driver-conformance run that discovers zero drivers now **fails** +(#4646); `check:i18n` fails on an undeclared authoring key rather than only on +bundle drift (#4804); and `check:react-conformance` is renamed +`check:react-declaration-parity`, because it compares two declarations and said +it compared a declaration to an implementation (#4472). + +#### Atomicity and durable migrations — ADR-0118, ADR-0119 + +- **`atomic` becomes a guarantee (#4612, D1/D4).** `batchData`'s + `options.atomic` promised "rollback entire batch on any failure" and delivered + a `break`: every write before the failure stayed committed, and the response + reported those rows `success: true` under the one flag whose job is to + guarantee they were undone. An explicitly atomic batch now runs inside one + `engine.transaction()`; rows come back `ROLLED_BACK:` / `NOT_ATTEMPTED:` with + the causal error, and **no row reports success**. On a runtime that cannot roll + back the request is refused with **501 `NOT_IMPLEMENTED`** rather than + degrading silently. `IObjectQLEngine.transaction` joins the slot contract, so + plugin space reaches it without `as unknown as` casts. + **The declared default flips `true` → `false`** — aligned down to what every + site already did, rather than up to what none of them did. +- **Its two siblings follow (#4620).** `deleteManyData` was fake-atomic (a + partial delete with no natural undo, reported as atomic) and `updateManyData` + ignored `atomic` entirely. Both now run the same shared atomic runner, so a + fourth copy of transaction handling cannot drift into a fourth lie. +- **The migration journal (#4617, D2).** Migration-class work does not fit in + one transaction: a million-row backfill cannot hold a write lock, and a + process *killed* defeats in-process rollback entirely. `runMigrationJournal` + (`@objectstack/core`) preflights every step's read-only validator before any + step writes, chunks the rows, runs each chunk inside `engine.transaction()`, + compensates newest-first on failure, and on restart resumes forward from the + first chunk lacking `chunk_done` — or unwinds, per the plan's `onCrash` + policy. The invariant that carries it: `chunk_done(i)` is written **inside** + the chunk's own transaction and `chunk_started(i)` autonomously before it, so + `started ∧ ¬done` has exactly one meaning. `sys_migration_journal` is + registered unconditionally, because recovery must be discoverable with zero + host wiring. Boot reconciliation and `os migrate resume` make an interrupted + run impossible to miss. +- **Actors are typed, not stringly (#4608 ADR-0118, #4586).** A non-user actor + is represented as `NULL` with an explicit `isSystem`, fail-closed, and + better-auth's real operator is now carried onto identity-table writes so + `sys_member` history stops saying "system". `sys_metadata_history.recorded_by` + is declared `Field.lookup('sys_user')` and was being filled with the **string** + `'system'` — an id that dereferences to nothing under any reading; it stores + `NULL` now (#4556). +- **Stored metadata gets a finish line.** `os migrate meta --stored` rewrites + `sys_metadata` rows in place (#4327) and now covers flow rows too (#4454); + `POST /meta/_migrate-stored` runs it without a shell; `saveMetaItem` + canonicalizes flow bodies on write, so a Studio edit heals a legacy row like + every other type's (#4542), and says so out loud when it had to skip + canonicalization (#4580); `duplicatePackage` stops minting pre-protocol flow + rows (#4498). + +#### Protocol & wire changes + +- **Batch per-row results deliver their declared shape (#4793).** The rows of + `POST /data/:object/batch`, `/updateMany` and `/deleteMany` had drifted from + `BatchOperationResultSchema`: the schema, the SDK's exported + `BatchOperationResult` and the reference docs all said `errors: ApiError[]` / + `data` / `index`, while the wire carried `error: string` / `record` and never + sent `index`. A TypeScript consumer written against the published type + compiled, validated, and read `undefined` at runtime. A conformance pin now + parses every emitted row against the schema. `ROLLED_BACK` and `NOT_ATTEMPTED` + are registered batch-row error codes. +- **`IAutomationService.getSuspendedScreen(runId)` is async (#4515).** A + synchronous signature could only read the engine's in-memory hot cache, so + after a restart a still-suspended screen run could be *resumed* (200) while + `GET …/runs/:runId/screen` answered 404 — the refresh-safe re-fetch failing in + exactly the situation it exists for. It now falls through to the durable store + that `resume()` rehydrates from. One-line fix: `await` the call. +- **`sys_comment` derives its access from the record its thread names (#4630).** + See Security corrections. +- **The app-metadata gate filters inside `areas[]` (#4722).** Response shape + tightens: gated navigation items no longer appear at all, and an area filtered + empty is stripped. +- **Discovery stops advertising routes for kernel-internal slots (#4318)** — + cache, queue and job have no HTTP surface. +- **Metadata audit history and global search sort by `order`, not `direction` + (#4674)**, and the `/meta` read/write/delete boundary settles on one canonical + type key (#4432). +- **Four ADR-0112 envelope defects found in the v17 verification sweep** are + closed (#4431, #4435, #4436, #4483), along with the `$search` field set. +- **A stored reference value that is an embedded record is no longer a valid id + (#4455).** `ReferenceIdValueSchema` was `z.string().min(1)`, and in SQL a + legacy embedded reference reaches storage as JSON *text* — a non-empty string. + So `os migrate value-shapes`, the evidence half of the ADR-0104 D1 gate, ran + the scan on a deployment carrying exactly the values it exists to find, was + told it was clean, and closed the gate. + +#### Metadata authoring & runtime changes + +- **A hook `condition` the platform cannot evaluate now ABORTS the operation + (#4775).** It used to `logger.warn` and `return false` — the hook simply did + not fire. "The condition said no" and "the platform could not work out what the + condition says" carry **opposite** risks depending on the hook, and collapsing + them into one outcome meant a `before*` guard silently let writes through. + Hooks that have been getting by on that skip will now fail the write; that is + the point, not a side effect. +- **A hook `condition` reads the record, and can express a transition (#4770, + #4784).** The gate evaluated against `ctx.input.data` — only the fields the + write happened to carry — so `condition: "record.done == true"` did not run on + the most ordinary updates there are. It now evaluates against **stored ⊕ + payload**, total over the object's declared fields, and the CEL scope binds + `previous` alongside `record`, which both published skill docs already taught. + Write a transition as `previous.done != true && record.done == true`. +- **Validation rules fail CLOSED (#4649).** A `script` / `cross_field` / + `conditional` rule whose predicate faulted was logged at WARN and **skipped**, + so the write went through while the rule stayed declared and enforced nothing. + The merged record is now total on update as well as insert, and a predicate + that still cannot be evaluated rejects the write with `VALIDATION_FAILED`, + naming the rule and the key it read. `severity` still governs blocking. +- **A `script` node is a function call (#4343).** It had four ways to name what + it ran and only one ran anything: `actionType: 'email' | 'slack'` were + logger-backed stubs that reported success and delivered nothing, `template` / + `recipients` / `variables` fed those stubs, and an inline `config.script` was + recognized and never executed (there is no server-side JS sandbox). What + remains is what worked — `config.function` (now **required**), `config.inputs`, + `config.outputVariable`. `script` and `subflow` config is parsed at execute + time. +- **A `user` field carries its target in the TYPE (#4438).** `Field.user()` + writes `reference: 'sys_user'` itself, but two callers read `field.reference` + raw and disagreed, so `?expand=` answered + `400 INVALID_FIELD … declares no target object`. Metadata authored without the + redundant key — hand-written JSON, an AI author, a Studio form — was read as + under-specified when it was complete. `referenceTargetOf` is now the single + arbiter both halves of the expand path read. +- **A `datasourceMapping` rule is routing, not a hint (#4462).** Map an object to + a Postgres datasource with a bad URL and the boot succeeded, `/ready` answered + 200, the datasource name appeared in zero log lines, the write returned 201 — + and the row was physically in the **default** store. A mapping that matches and + names a datasource with no live driver now throws. +- **`datasource.config` is parsed against its driver's contract (#4410)**, and + the driver factory's four legacy `??` fallbacks graduate into an ADR-0087 + conversion instead of staying as consumer-side aliases (#4456). +- **Decision routing has one working model (#4414, #4440).** The two shapes that + could never route are gated at author time, the inert `config.condition` is + flagged, `isDefault` is enforced, and an unclaimable branch label stops being + swallowed. `evaluateCondition` decides its dialect from the source rather than + from the caller (#4336). +- **The `record:*` blocks are withdrawn from the react tier (#4413)** — no + renderer read the props they published. +- **Flow node-type validation waits for the plugin vocabulary to close (#4771)**, + so an approval flow is no longer mis-reported as "will fail at runtime". +- **`Seed.env` is enforced (#4836)** — environment-scoped datasets no longer seed + everywhere — and a seed replay keeps its per-org tenant stamp as an id rather + than resolving it as a natural key (#4644). +- **A `defaultValue` runtime token never becomes a column DEFAULT (#4560).** + +#### Security corrections in this window + +- **Comments were readable by anyone who could guess a thread id (#4630).** + Attachments derive visibility from the parent record; comments derived + nothing. On the same record, the same user who got `200, 0 rows` for the + opportunity and its attachments got `200, 1 row` for its comments — and could + `POST` a new one. `sys_comment` now derives its access from the record its + `thread_id` names. +- **Navigation gates inside an area were rendering-layer politeness (#4722).** + `filterAppForUser` is the server-authoritative visibility gate for app + metadata on `/meta`, but it only walked the app's **top-level** `navigation` + tree — `item.areas` was never read. `requiredPermissions` / `requiresService` + written on an item inside an area were enforced only by the client renderer, + so the item and its `objectName` / `pageName` / `componentRef` targets arrived + in the `/meta` body regardless. The same `filterNav` now runs on every + `areas[].navigation`, and the service-probe surface was widened to match — a + probe narrower than the filter would strip a live entry. Note the deliberate + asymmetry: `visible` (CEL) is still client-only at every level. **If it must + never reach the browser, write `requiredPermissions`, not `visible`.** +- **Two area-level gates failed open (#4651).** `app.areas[].visible` and + `.requiredPermissions` were not inert keys — an author wrote + `requiredPermissions: ['sales.admin']` on a navigation area, got a clean parse + and a stored value, and the area with everything under it was served and + rendered to **every user**. Both are removed, with a prescription; this is a + real migration, not a rename. +- **An unscoped multi-delete of `sys_attachment` is refused (#4757)** rather than + authorized. +- **`/analytics/query` is scoped to the caller's readable records, and a measure + over a missing field is refused (#4467, #4437).** +- **Deactivating or deleting a sharing rule now withdraws its materialised + grants (#4433, #4434)** — and `DELETE` stops answering 500. +- **The org-admin auto-grant can actually revoke (#4640)** — `tryDelete` was + calling the engine's `delete` with the wrong signature, so demoted admins kept + tenant admin. +- **Permission-set projection writes only spec-declared keys, and a failed + backfill becomes loud (#4669).** +- **AI routes carry the capability channel onto `req.user` (#4705)**, and a + degraded tenancy posture no longer hands out a default organization (#4423). +- **OTP hardening.** The per-number send budget counts in the shared store + rather than per node (#4790); the cooldown's retention follows the declared + value instead of a hard-coded hour (#4808); and the rate-limit counters + resolve the kernel cache lazily, which fixed both a spurious warning and the + functional hole that shared rate limiting never engaged (#4772). +- **The issuer is the provider's to declare (#4552)** — Google account links + stopped resolving under a synthesized one. +- **Approvals.** An admin override of a staffed approver slate is recorded *as* + an override (#4466); the record lock holds for predicate (`multi`) updates + (#4778); stranded requests nothing was looking at are findable (#4469); a + decision can no longer report success while its flow stays parked (#4420); and + `resume` enforces the suspended screen's declared field contract (#4477). +- **Three auth-gate disconnects close (#4509)** — `email_template` bridges to the + mail service, `job` and `validation` close their doors. +- **The ADR-0104 fresh-datastore attestation concludes on this boot's own data + (#4769)**, not on the emptiness it remembers from before the boot wrote. + +#### New backend capabilities in this window + +- **Predicate writes get an honest bulk event contract (#4639).** A `multi: true` + update or delete reaches `updateMany` / `deleteMany`, which resolve a row + **count** — satisfying neither `DataEvent.recordId` nor `before` / `after`, so + the engine used to fabricate an event with `recordId: ''`. New `BulkDataEvent` + (`data.records.updated` / `data.records.deleted` — note the plural) carries + `matched` and no `recordId`, so a consumer knows from the type alone what is + coming. Webhooks gain opt-in `bulk_update` / `bulk_delete` triggers — + deliberately **not** extra sources for the per-record ones — and the SDK gains + `client.events.subscribeBulkData`. A predicate matching zero rows publishes + nothing. +- **`subscribeData` and `subscribeMetadata` deliver what they declare** — real + `DataEvent`s (#4626) and real `MetadataEvent`s (#4602), with loud boundary + validation. +- **`@objectstack/client-react` grows bulk-write hooks, and `useAutoRefresh` + refreshes on predicate writes (#4678).** Five hooks stopped looping on + dependency identity (#4693, #4694), and the package finally has a test harness + pinning realtime behavior (#4682). +- **Blueprints can say what they compute.** A `formula` field states its + `expression` (#4577), and a blueprint can declare roll-ups, including + conditional ones (#4425). +- **Stored `lookup` references that resolve to nothing are reported (#4551)** — + an inspection that reports and never blocks. (It no longer misfires during + shutdown, where every `os migrate` subcommand was reporting `sys_metadata` and + `sys_view_definition` as unreadable — #4747.) +- **Aggregate bulk dispatch works end to end (#4461):** `_selectedIds` passes the + action param gate, so an aggregate bulk action makes one call instead of N. +- **A roll-up registered at runtime computes without a restart (#4427)**, and a + just-saved overlay is dispatchable immediately rather than after the next + listing (#4521). +- **Analytics correctness continues.** A percentage measure carries its SCALE, so + a ratio of 1 reads as 100% (#4442); a measure a query never reported reads `0` + for a count/sum on every merge seam, `compareTo` included (#4708); and + `compareTo` applies each measure's own filters, so `__compare` is the + same measure as the column beside it rather than a different one (#4820). +- **Boot and operations tell the truth.** A `sys_metadata` DDL failure is loud — + only "table already exists" may be silent (#4728); the history `event_seq` is + never invented from a read that failed (#4825); a best-effort degradation that + costs **durability** logs `error`, not `warn`, with a gate enforcing the rule + (#4632); declarative `defineJob` cron jobs are actually scheduled (#4567); and + the SQL outboxes stop writing `updated_at` on UPDATE, which was flooding the + `pnpm dev` console (#4765). +- **`os serve` distinguishes "the multi-org package is absent" from "the plugin + refused to mount" (#4818)**, and resolves the enterprise organizations package + from the **host app** rather than the framework (#4699, #4700). +- **A `RETURNING` write persists on `driver-sqlite-wasm` (#4518)**, unblocking + cold-boot e2e. + +### Landed since 17.0.0-rc.2 + +These changes are on `main` after the `rc.2` cut and **roll into 17.0.0-rc.4**. +Counted across the `17.0.0-rc.4` sections of the package changelogs, the cut +released **360 changesets — 44 `major`-class, 94 `minor`, 222 `patch`**, which +makes it the largest of the train's RC cuts (`rc.0` released 258, `rc.1` 262, +`rc.2` 166, `rc.5` 10). + +**Why there is no `rc.3` window section.** `17.0.0-rc.3` was tagged for every +package on 2026-08-03 by a `chore: version packages (rc)` run, but the round +consumed exactly one changeset — `BulkActionParamSchema`'s `options[]` entry +gaining passthrough (#4001) — and left every other package with nothing but an +*Updated dependencies* bump. Those `## 17.0.0-rc.3` changelog sections no +longer survive on `main`, and the Console pin did not move in that round. So +this one window section spans the `rc.2` cut through the `rc.4` cut rather than +splitting at a tag that records nothing. + +As in the windows above, landings that belong to a section higher up the page +are documented **there** rather than repeated here. + +- **Declarative `apis:` goes from silent no-op to live, in two steps.** The + surface was zero-execution end to end and said nothing about it: metadata + loading accepted `apis:` and `GET /api/v1/meta/api` returned every endpoint + with every key intact, while no route was ever mounted — so a declared path + died at Hono's `notFound` rather than at the dispatcher. It first learned to + **refuse loudly**, retiring the `ApiRegistry` family (#4936, #4939); the + blanket refusal then narrowed to per-endpoint publish gates and declared + endpoints **went live** (#5111, #5040 E7). +- **#4001 closes the authorable surface, batches 9–20.** Unknown keys are now + rejected on the flow-node config contracts (batch 9), `control-flow` and + `state-machine` (batch 10), nine automation shapes (batch 11), the ETL + authoring contracts (batch 12), the responsive/SDUI styling shapes (batch + 13), action-param options, public sharing, report sorting, the dataset + semantic layer and dashboard widgets (batch 14), all 14 `ui/theme` sites and + 5 of 7 `ui/chart` sites (batch 15), and `AriaProps` (batch 16). View + sub-blocks (batch 18) and object inner blocks (batch 20) stop dropping + unknown keys instead of reporting them. `ui/component.zod.ts`'s 29 sites were + judged **no gate** and deliberately left open (batch 17) — a recorded + verdict, not an omission. `ViewItemSchema` splits into an authoring schema + and a wire variant. +- **ADR-0049 enforce-or-remove reaches its widest sweep.** Retired in this + window: the dashboard widget action trio plus `aria`, and the build gate that + enforced a button nobody renders (#5010); `indexes[].type` and + `indexes[].partial`, two authorable index keys no driver ever read (#5248, + #4943); `connector.rateLimitConfig` and the whole outbound rate-limit shape, + whose engine never existed (#4911); `dashboard.widgets[].responsive` (#4876); + `HookContext.session`'s deprecated position key, declared and read by two + dead branches but never produced (#5050); `HttpServerConfigSchema` (#4938); + `NotificationActionSchema` and `EmbedConfigSchema` (#5015); nine theme token + groups emitted and read by nobody (#5021); the five `ui/` interaction config + modules (#4988); `IStorageService.list(prefix)` (#5540); the field-mapping + `transform` key and `FieldMappingTransform` (#5552); and the exported + `HttpServer` delegating wrapper. +- **The caller's positions converge on one spelling.** Action-body + `ctx.session` starts emitting the canonical `positions` alongside the + deprecated predecessor key it replaces (#5613); that predecessor is then + removed outright, leaving `ctx.user.positions` as the only spelling on + action bodies and AI route handlers (#6011). +- **A misconfigured provider fails the boot instead of degrading silently.** + `OS_EMAIL_PROVIDER=resend/postmark` without an API key no longer becomes the + log transport (#5132), and a misspelled `OS_SMS_PROVIDER` no longer does + either (#5713). +- **Wire shapes stop having two answers.** `GET /meta/:type/:name` answers + exactly one body shape; a sort node spelling its direction `direction` is a + 400 rather than a silently reversed page (#4721); `DeleteDataResult` declares + the schema it names — `success`, not `deleted` (#5638); and + `@objectstack/spec/openapi.json` stops describing routes it does not own, + shrinking to the contract half it does (#5744, #5588). +- **The driver contract stops writing the object name twice.** `IDataDriver`'s + query parameter becomes `DriverQuery` (#5181) — the first step of the + narrowing that finishes in the open window below. +- **Tenancy and durability corrections.** `organization/create` is judged + against the **effective** tenancy posture, so a deployment with no + organization wall can no longer create organizations (#5261); + `HierarchyScopeContext.organizationId` becomes the authoritative tenancy + field and is required (#5858); and a `sys_file` / `sys_upload_session` write + that never landed stops reporting success (#5216). +- **Two dialect convergences.** The retry policy's last two spellings meet + (#4964 `flow.errorHandling`, #4962), and `dashboard.widgets[].compareTo` + converges on the analytics executor's contract (#5011). + +#### New in Console — bundled objectui advanced `f5bc4c78be76 → f995a452d2ca` + +One pin move, released as **@objectstack/console 17.0.0-rc.4**: 98 non-merge +objectui commits, of which 64 of 65 changesets release; the enumeration is +`console-f995a452d2ca`. This is the move that carries the bundle's two +author-facing breaking migrations — **field widgets receive their metadata on +one key, `field`** (objectui#3233) and **flow node geometry is the spec's +`FlowNode.position`** (objectui#3172) — both documented under *Breaking changes +& migration* above. + +### Landed since 17.0.0-rc.4 + +These changes are on `main` after the `rc.4` cut and **roll into 17.0.0-rc.5**. +A short window: **10 changesets — 2 `major`-class, 4 `minor`, 4 `patch`**, +counted across the `17.0.0-rc.5` sections of the package changelogs. Nine are +listed below; the tenth is the Console pin move. + +- **`system-data` no longer grants CSV import by default** (#4671). + `managedBy: 'system-data'` narrows from `create/import/edit/delete/exportCsv` + to the same set with `import: false`, leaving `platform` as the only bucket + that grants `import` by default. Eight in-repo objects lose the import + wizard, three of them the RBAC join tables that decide who can do what. + Restore any one of them with `userActions: { import: true }` on the object — + only that verb, the rest still follow the bucket default. The authorization + boundary is unchanged: `import` is an affordance that decides whether a UI + entry renders, and every imported row still passes `DelegatedAdminGate`, RLS + and permission-set adjudication. What changes is leverage — one bad CSV is a + bulk grant. Recorded as the #4671 addendum to ADR-0103. +- **`subscribeMetadata`'s `type` narrows to `MetadataEventSubject`** (#4627) — + subscribing to an event the contract can never deliver is now a compile + error. +- **`engine.transaction` tightens, batch 1** (#5696) — `opts.require` is + fail-closed, and the handle carries an `owned` signal. +- **Transaction handles stop leaking across datasources** (#5351, #5696) — a + business write refuses loudly, and the system ledger moves out of the + transaction so it lands on its own. +- **A connector action can declare what it did upstream** (#4395), which is + what makes `connector_action` countable. +- **`XParsed` naming reaches every schema that has a parsed state** (ADR-0122, + #5551). +- **`os migrate summary-nulls`** (#6063) backfills roll-up count and sum + columns left `NULL` by pre-seed inserts. +- **`runAs: 'system'` `create_record` stamps all three ADR-0118 columns** + (#5494) — organization, owner and creator are non-`NULL`. +- **The strictness ledger gains a ninth verdict, `covered`** (#5249) — an + honest cell for a shape fragment with no gate and no parse whose every + consumer already guards. + +#### New in Console — bundled objectui advanced `f995a452d2ca → 7dfbeb704e1e` + +One pin move, released as **@objectstack/console 17.0.0-rc.5**: 28 non-merge +objectui commits, 9 of 9 changesets releasing, all `patch`; the enumeration is +`console-7dfbeb704e1e`. No author-facing breaking migration. + +### Landed since 17.0.0-rc.5 + +These changes are on `main` after the `rc.5` cut and **roll into +17.0.0-rc.6**, cut on 2026-08-10. The cut released **425 changesets — 36 +`major`-class, 123 `minor`, 266 `patch`**, and none that release nothing — +making it the largest round of the train, ahead of `rc.4`'s 360 (`rc.0` +released 258, `rc.1` 262, `rc.2` 166, `rc.5` 10). The window was still open +when the entries below were first written against 135 pending changesets; the +three bullets after them cover what landed between that draft and the cut. + +- **ADR-0122 phase 2 — the bare type name becomes the AUTHOR state.** 1384 + aliases flip and 102 `XInput` synonyms retire, completing the convention + whose `XParsed` half landed in the previous window (#5551). +- **The driver query contract finishes narrowing.** `DriverQuery` reaches + `aggregate` and `distinct` on memory and mongodb (#6212 batch C) and five + further drivers (#6075); `SqlDriver.distinct`'s third parameter becomes a + bare `FilterCondition`, so a spelling that silently returned the whole set no + longer compiles (#6320); `analyzeQuery` and `findWithWindowFunctions` stop + taking `any`, and the window door carries its own flat-shape types (#6212 + batches A and E); and the two undeclared `aggregate` / `func` aliases retire + (#6212 batch B, #6321). +- **ADR-0049 enforce-or-remove continues.** The L2 ETL layer retires — + `automation/etl.zod.ts` had no executor while the sync architecture document + was still recommending it. So do `system/http-server.zod.ts`'s runtime + vocabulary; the widget-registration vocabulary and five doorless i18n shapes, + with `FieldWidgetProps` deliberately **kept**; `ViewProtocol`'s five + viewId-addressed methods and their ten schemas (#6239); and `array_agg` / + `string_agg` from `AggregationFunction`, with `count_distinct` deliberately + kept (#6188). The SDUI component props are reconciled against the renderers + that serve them — 4 keys retired, 9 declared (#5775). +- **`composeStacks` stops last-wins on `i18n`** (#5051) — the same value + passes, a genuine conflict errors with a prescription. +- **`HierarchyScopeContext` carries the tenancy posture** (#6139), so a + single-posture `DEPTH` scope is legal rather than rejected. +- **`HookContext.api` narrows from `z.unknown()` to `IScopedContext`** (#5945) + — the first hook the documentation teaches finally compiles. +- **A `view` body must be a view before the union judges it** (#5599), and an + action param's `options[]` can speak a per-option `visibleWhen` (#5016). +- **One driver vocabulary, and `mongo` becomes `mongodb`** (#6345). `spec` has + owned the driver alias table since #4410 so that the id selecting a *driver* + and the id selecting that driver's *config contract* cannot disagree — but + the table never reached the two boot hosts, so `OS_DATABASE_DRIVER=pg` was + accepted by `os start` and **refused by name** by `os migrate`, and `libsql` + the same. Both now read the one table, `DRIVER_CATALOG` publishes `mongodb`, + `turso` gains a config contract, and the datasource factory can no longer + fall through to `memory` when it fails to recognise a name. +- **Doors that were open only by omission close.** A paused node whose + descriptor never declared `resumeAuthority` is now fail-closed — the generic + resume route refuses it with 403 until the descriptor opts in with `'any'` + (#5561; zero in-repo executors affected). `api` becomes code-only: the + runtime create door the endpoint matcher could never read answers 403 + `NOT_CREATABLE`, and the `**/*.api.ts` artifact route is untouched (#5488). + `registerHook` refuses an empty object target, and `engine.find` refuses a + formula field in `orderBy` rather than sorting on nothing. +- **The ADR-0045 publish gate gets its own machine-managed key** (#4829), so + `app.hidden` goes back to meaning navigation — and the built-in Account app + stops 404ing for every normal user. Nothing to rewrite by hand: stored rows + carrying `hidden: true` convert to `_unpublished: true` on read, and in place + via `os migrate meta --stored --apply`. +- **More enforce-or-remove, on the surfaces authors actually type.** + `GET /api/v1/notifications` loses the `cursor` half it honoured on neither + side of its contract — a caller paginating by the published shape re-read the + first window forever, both pages parsing green (#6361). `ExportFieldMeta` + sheds eight constraint keys (#6536); `ImportRequest.runAutomations` declares + the default the import route actually applies (#6704); `AggregationNode.distinct` + retires (one face honoured it, five ignored it); `ActionDescriptor.isAsync` + goes as a second spelling of `supportsPause` (#6748); `global_nav` leaves + `ACTION_LOCATIONS`; `manifest.loading` leaves the plugin contract; three + zero-reader SDUI page-component props retire and four more are declared, with + `page:tabs.type` renamed `tabStyle` (#5775, #6776). + +#### New in Console — bundled objectui advanced `7dfbeb704e1e → 0cf8f0f70d10` + +One pin move, released as **@objectstack/console 17.0.0-rc.6**: 66 non-merge +objectui commits, 24 of 24 changesets releasing; the enumeration is +`console-0cf8f0f70d10`. Two entries carry the author's own breaking +annotation, but both are TypeScript +export-surface changes inside objectui's own npm packages — the `GestureType` / +`GestureConfig` rename (objectui#3363), and objectui tracking the +`@objectstack` family at `17.0.0-rc.5` (objectui#3560). `@objectstack/console` +ships a frozen SPA build at the pin SHA and does not forward those type +entrypoints, so **no new metadata migration is registered for this move** — the +changeset records that verdict against ADR-0087. The same changeset also lists, +by subject rather than by count, the 42 commits in the range that carried no +changeset of their own. + +### Landed since 17.0.0-rc.6 + +These changes are on `main` after the `rc.6` cut and **ship in 17.0.0** — the +last window of the train, released by the cut that exited Changesets pre-mode +and published to `latest` on 2026-08-14. The cut released **375 changesets — 11 +`major`-class, 99 `minor`, 265 `patch`**, and none that release nothing, making +it the train's second-largest round behind `rc.6`'s 425 and ahead of `rc.4`'s +360. The window was still open when the entries below were first written +against 374 pending changesets; the one landing between that draft and the cut +is called out under *Metadata, packages & authoring gates*. + +As in the windows above, landings that belong to a section higher up the page +are documented **there** rather than repeated here. + +#### Credentials stop being writable in the clear (#7990, #8082, #8336, #8075) + +The largest coherent story in this window. `sys_metadata.metadata` is served +back by the ordinary data API and a datasource or connector artefact is +persisted whole — so every schema that *accepted* an inline credential was +storing that credential in cleartext at rest. Four doors close, in the order +authors were pushed through them: + +- **The credential keys.** Driver `config.password` (SQL/mongo) and + `config.authToken` (turso) are declared-unwritable: writing one fails `tsc` + (the input type is `never`) and fails the parse with a prescription naming + the replacement. The former alias spellings (`passwd`, `pwd`, `token`, `jwt`, + `auth_token`, `authtoken`) carry the same refusal. On the connector side, + `DeclarativeConnectorEntrySchema` now refuses a non-`none` `authentication` + on **every** authored entry, catalog descriptors included — previously only + provider-bound instances were covered (ADR-0097 §3), so a descriptor could + publish an inline `token` / `key` / `password` / `clientSecret`. +- **The URL that carried the same secret one syntax over.** `config.url` + accepted `postgresql://user:password@host/db`, which landed in `sys_metadata` + cleartext exactly as `config.password` did — and the key refusal itself + steered authors (very often AI authors) into the URL form. A shared + value-level parse now refuses a userinfo segment carrying a non-empty + password across the four URL-bearing driver schemas. A bare username + (`user@host`) stays accepted, so an untouched "Save" on a legacy row still + works, and runtime-environment DSNs (`OS_DATABASE_URL` and friends) are + unaffected by construction — they never pass through the authoring schema. +- **The placeholder escape hatch.** `${…}` syntax in connection-material driver + config keys is refused at publish (#8336). Placeholders in authored metadata + are resolved by nothing and reach the database client verbatim — measured. +- **The two dead families whose only distinctive feature was a credential + sink.** `ExternalDataSource` / `ExternalFieldMapping` / `ExternalLookup` and + `MessageQueueConfig` / `TopicConfig` / `ConsumerConfig` / `DeadLetterQueue` + are removed outright — 8 definitions, 22 exported names — with zero consumers + repo-wide and inline-credential sinks in both (#8075). + +There is deliberately **no automatic rewrite**: moving a cleartext credential +into `sys_secret` requires encrypting it through a running secret binder, which +a source transform cannot do, and auto-deleting the key would silently drop a +live credential. `os migrate meta` surfaces both changes as structured TODOs. +Setup → Datasources already does the right thing — its masked secret field +encrypts into `sys_secret` and writes `external.credentialsRef`; it never wrote +`config`. To make that route declarable, `DatasourceSchema` now accepts +`external.credentialsRef` — and only it — on `schemaMode: 'managed'` (#8588). + +The same sweep hardens what was already stored. The OIDC SSO `clientSecret` is +encrypted at rest; webhook custom `headers` are encrypted rather than riding +`definition_json` in cleartext (#7986); the subscriber's HMAC signing secret is +no longer readable from `sys_webhook` over the data API (#7799) and stops being +persisted on every delivery row (#7722); `sys_scim_provider.scim_token` and +`sys_oauth_application.client_secret` get their posture pinned; the datasource +read path stops serving stored credentials in cleartext; and +`sys_http_delivery`'s callout credentials leave the generic data-API read +(#8118). Two new `internal: true` fields — `sys_api_key.key` (#7728) and +`sys_session.token` — never return on the generic data path at all, and the +aggregate guard refuses `internal` columns rather than only the +`secret`/`password` *types* (#7922). Finally, the credential write door refuses +`""` (#8616): a secret field could be set to an empty credential that every +read then reported as masked. + +**Settings fail closed.** A write that would persist a secret through the +base64 `NoopCryptoAdapter` is refused rather than stored (matching the engine's +posture), encrypted values are redacted at the REST read boundary (#7522), +rotation actually repoints the secret handle and destroys the retired +ciphertext, and the refusal gets a wire spelling — `SETTINGS_CRYPTO_UNAVAILABLE` +— so a client can branch on it (#8273). A webhook holding an encrypted signing +secret re-arms the moment the `CryptoProvider` registers instead of ~60s later +(#8022); one whose stored secret or header map cannot be recovered **parks** +the subscription instead of arming it and delivering unsigned or with the +headers missing (#8542, #8558). + +#### Driver text stops reaching the caller + +A caught driver error was being interpolated into client-facing messages across +the metadata plane, which discloses dialect, schema and sometimes data. The +whole class is closed: the generic message path (#8136), the batch verbs' +`failed[]` `message` and `code` (#8333, #8441), the seed loader's +`errors[].message` (#8442), the package-publish door's `seedApplied` (#8443), +the direct-mount package door's leaky 5xx (#8086), a bulk write's per-row +`errors[].message` (#8502), and the shipped Postgres and bare-SQLite phrasings +of a driver failure. A package publish that faults in the driver is answered as +a server error rather than passing the text through. + +#### Breaking changes since rc.6 + +Beyond the credential doors above: + +- **`view.exportOptions` adopts the object form the renderer actually reads** + (#8010). `ListViewSchema` typed it as a bare format array while the only + renderer reads an object (`exportOptions.formats`, plus `maxRecords`, + `includeHeaders`, `fileNamePrefix`, `streaming`). A project following the + published type wrote `exportOptions: ['xlsx']`, the renderer saw + `.formats === undefined` and fell back to `['csv','json']` — so **no + declaration was both type-legal and functional**, reported from a live + customer project. The same ruling removes `'pdf'` from the format enum: PDF + export was declined platform-side (#1301), so the member was + declared-but-unrenderable. +- **Three pass-through-only list-view keys retire** — `striped`, `bordered`, + `virtualScroll` (#7176). The liveness ledger graded them `live`, but the + citations were forwarding copies, not appliers: the chain ends at + `ObjectGrid.tsx` with zero occurrences of any of the three. Delete the keys — + the grid frame is the renderer's own constant, and large datasets page via + the view's `pagination` block. +- **`field` loses `allowRuntimeCreate`** (#7893). A standalone + `PUT /meta/field/{object}.{name}` answered `200 {"success":true}`, persisted a + row, and the field **never** appeared in the object's `fields`. It now answers + 403 `NOT_CREATABLE` naming the remedy. Adding a field at runtime is not lost: + `object` keeps `allowRuntimeCreate: true`, so write the whole object — what is + withdrawn is a second, broken spelling of the same operation. +- **`engine.update()` loses `upsert`** (#8057) — declared on both update-options + schemas and on the engine's allowlist, read by no engine or driver path. A + caller passing `{ upsert: true }` got silence: not a refusal, not an upsert. +- **`/api/v1/auth/config` stops advertising `passkeys` / `magicLink`** (#7481). + Both flags were served from introduction and read by no client, so the payload + advertised two sign-in methods no user could reach. `AuthPluginConfig.plugins.*` + is unchanged — this narrows the served payload, not the server configuration. +- **`FieldReferenceSchema` leaves the list comparand positions** (#7596). Both + `$between` endpoints carried it and `$in`/`$nin` were `z.array(z.any())`, + which admits a reference — a form no backend has ever implemented. The + in-memory evaluator reads `$field` only off a non-array object, so `$in` + compared the reference *object* against stored values and never matched. +- **`submitBehavior.url` is ruled and enforced** (#7496) — relative-only, + declared-field interpolation, URL-escaped. +- **Cross-tenant uninstall must be declared** (#7780). `deletePackage` selected + rows with `{ package_id }` and added an organization predicate only if the + caller supplied one, so an omitted argument matched **every** organization's + rows — measured during #7705 at 5 of 5 deleted, including a foreign + organization's. A call naming neither an organization nor `allTenants` is now + a 400. +- **The memory driver declares itself single-tenant and refuses to boot + multi-tenant** (#6915). It implements no row-level tenant isolation — it never + reads `DriverOptions.tenantId` — so a multi-tenant deployment backed by it did + not fail; it served cross-tenant reads, updates and deletes silently. +- **`$contains` is case-SENSITIVE on MongoDB and the memory driver** (#6682). + The hardcoded `$options: 'i'` beside the `$regex` is gone from all four arms, + so `{ name: { $contains: 'acme' } }` no longer returns `ACME Corp`. This is + the last driver arriving at #4706 Q2 = A, after #6518 flipped the SQL family. + Both directions of the defect mattered: the fold over-matched — on an RLS read + scope that is over-reach, not merely a loose filter — and it folded the whole + Unicode range. +- **Closed query-parameter sets become REST ingress policy** (#7606), starting + with the first tier of data read routes. Handlers read the keys they knew and + ignored the rest, so a misspelled or invented parameter was silently dropped + and the caller got a plausible `200`. It failed in both directions: a dropped + `?objects=` fans a search across every object and a dropped `?fields=` + returns the whole record, while a dropped key inside a filter answers `200` + with zero rows — each shaped exactly like a legitimate result. Tolerated + traffic breaks here, deliberately. +- **A number field's declared `scale` is enforced — by rejection, never + rounding** (#7501). `scale: 0` accepted `11.5` and stored it verbatim through + both REST create and CSV import. It now answers 400 `VALIDATION_FAILED` with + field code `max_scale`; silent rounding would be silently altering data. +- **Two `sys_audit_log` action values retire** — `export` / `permission_change`, + then `restore` (#8315, #7675) — declared actions with no writer anywhere. + +#### Security corrections since rc.6 + +- **A tenant-scoped write with no active organization is refused**, naming what + is missing (ADR-0123 D2, #8247/#8208) — and a user's first session no longer + predates their membership, so its audit rows carry a tenant (#8245). +- **Uniqueness becomes per-organization wherever it was tenant-scoped.** A + declared unique index that ignored the tenant wall let one organization's row + block another's: `sys_setting` (#8555), `sys_position.name` (#8468), + `sys_user_preference` and `sys_capability` (#8323), five more across + plugin-security / plugin-sharing / plugin-webhooks / platform-objects / + service-messaging (#8554), and two beyond those (#8577). The tenant-scope + index is now declared whenever an object carries `organization_id` — whether + the platform provisioned that column or the author declared it (#8459) — and + `GET /meta/object/:name` serves it (#8375). +- **Sharing and invitation reads get their organization predicate.** A + `manage_sharing` holder with no active organization no longer reads every + tenant's sharing rules (#8158); `getRule`'s by-id branch is scoped to the + caller's organization (#7761); deleting a platform-global sharing rule is + refused to org-scoped callers (#7795); and a plain member can no longer read + the organization's whole invitation ledger (#8095), while a + `delegated_admin` can now read the invitations it issued (#8240). +- **`$expand` no longer discloses records the caller is 403'd from** — the + #2850 expand waiver is removed (#7626). +- **Federated objects stop being scoped by columns they do not have.** The + tenant wall no longer scopes a federated object by a phantom anchor (#7835), + the org-scope predicate is not injected onto external objects (#7738), and + minting a share row on a federated object whose `owner_id` is the platform's + injected anchor is refused (#8119). +- **Two RLS write-side holes close.** A `check`-only write policy no longer + disables both write-side row gates — a caller who could not read a record + could write it by id — and a by-id write target must be inside the caller's + readable set when only select-scope RLS is authored. Permission pre-image + probe faults now propagate instead of reading as absent rows (#7505). +- **`public_read_write` means what it says** (#8023): the object is writable by + everyone the access matrix grants `edit`, not only by each row's creator. + An app-declared permission baseline **composes** with the platform + `member_default` instead of replacing it (#7555), applied additively on + `/auth/me/permissions` and `/me/apps` (#7608). +- **Enumeration oracles closed.** `DELETE /reports/:id` and + `DELETE /reports/schedules/:scheduleId` stop telling a caller whether an id + exists; `GET /meta/app/` reports a permission denial instead of absence. +- **`403 PERMISSION_DENIED` stops handing business users internal authorization + vocabulary** on the object CRUD gate and the row-level/capability refusals, + and the dispatcher stops answering a denial's internal payload (#7450). + `controlled_by_parent` write refusals split by true semantics — three of the + six legs stop answering `PERMISSION_DENIED`. +- **Anonymous deny is consulted before the capability answer** on `/ai/**` + (#7653) and `/security` (#7911). +- **Impersonation takes effect for bearer clients** (#4467/#8049 lane work) — + the caller's token is rotated and `stop-impersonating` recovers the admin — + and `/auth/change-password` clears the force-change flag and enforces + password-reuse on the bearer lane, not only on cookies. +- **A member's stored membership grade is canonicalised at the write** (#8317), + so an org admin can no longer remove an owner; a member can revoke their + **own** API key (#8053); and `member_default` grants owner-scoped read on the + personal inbox (#7344). + +#### Protocol & wire changes since rc.6 + +- **`like` / `ilike` stop being folded onto `$contains` at the wire** (#7536), + and `$search` compiles to `$icontains` so textual search is actually + case-insensitive. +- **A `where` on a virtual `formula` field is refused, not answered with zero + rows** (#8296) — as is an unknown field inside `where` / `$filter` / a filter + AST (#7534), a dotted `fields` / `$select` entry (#7532, which used to widen + the response to every field), and a repeated `?filter=` (#7390). +- **A bulk write that stops now reports every record** — `NOT_ATTEMPTED` rows + instead of a truncated `results` array — and a per-row `errors[].httpStatus` + carries the status its producer declared, in any spelling (#8570). +- **A hook refusal that declares `statusCode` reaches the wire with that + status** rather than `500 INTERNAL_ERROR` (#7525), a crashing hook body + answers the sanitised fault envelope rather than a raw `TypeError` at 400 + (#7543), and one error envelope now spans the record-sharing family (#8111), + the `/security/suggested-bindings` trio (#7981) and the `/security/explain` + pair (#8073). +- **`UNIQUE_VIOLATION` 409s name the conflicting field**, matching the bulk path + (#7821), and malformed flow definitions answer 400 `VALIDATION_FAILED` rather + than 500 on both `POST` and `PUT /api/v1/automation/:name` (#8055, #8123). +- **Five ledgered-but-dead routes are mounted, and the class that hid them is + gated** (#7526); the route ledger gains live-mount parity. +- **Exported `datetime` cells render in the business timezone, not UTC** + (#8373). + +#### Metadata, packages & authoring gates + +- **An authored OWD is required at the runtime object door** (#8600), completing + the #7891 flip — `runtimeTypes` gains `object`, and ADR-0094 R2's + external-wider arm retires. `METADATA_CREATE_SEEDS.object` authors its + org-wide default explicitly rather than leaning on what the runtime resolves. +- **`validateSecurityPosture` gates runtime publishes** for `permission` / `book` + writes (#8309), the ADR-0091 seed pair (#8307) and the `views[]` + visibility-predicate family (#7220). +- **The `object-*` block family enters `ComponentPropsMap`** (#7751), so a + typo'd key inside a data-bound block's `properties` is caught at authoring + time with a did-you-mean. #4001 continues with batch A — the 31 SDUI + component-props shapes — and batch B, the memory driver's `persistence` + sub-shapes. +- **Uninstall stops leaving debris.** It removes the non-object metadata a + package shipped (#7221), no longer orphans env-wide `sys_metadata` rows + (#7705), and a refused uninstall now mutates nothing (#7970). Disabling a + package stops its objects being served, and a failed uninstall stops + answering 200 (#7557). +- **The `field` overlay lock is enforced at the live route** (#7743) — an + artifact-backed field PUT is refused rather than accepted 200 — and an + object's overlay row is treated as a base layer, not its resolved schema + (#8027). +- **A tenant's own rename beats a package extension's scalar** (#8620, + ADR-0029 D9.2a) — the one landing between this section's draft and the cut. + `mergeObjectDefinitions` applied an `objectExtensions` entry's `label` / + `pluralLabel` / `description` last, onto whatever base it was handed — and + D9.2 makes the tenant's overlay that base. So a rename saved through Studio + answered 200, was visible under `?layers=true`, and was overwritten inside + the fold before any read served it. An extender's scalar now applies only + while the fold's base still carries the packaged owner's value; a base that + has diverged was authored by the tenant, and the extender yields. It is the + comparison-based mechanism #8284 established for the i18n catalog one layer + up — the same predicate, now exported from `@objectstack/spec` and imported + by the registry rather than re-spelled — so one rule covers both layers. No + provenance flags and no migration: the question is answered from two values + at fold time. +- **`OS_METADATA_WRITABLE` no longer unlocks a write into a read-only package** + (#8146), and the metadata write refusal stops depending on deployment + topology (#8184) while reporting the package door it hit — + `ITEM_LOCKED` / `WRITABLE_PACKAGE_REQUIRED`. +- **`put().version` identifies the bytes actually stored** — the hash is taken + over the serialized form (#7856). +- **A plural metadata read can say it is known-partial** (#6504), and a + per-type read-path redaction seam lands in `kernel` with one definition of + "what is a credential key" in `data` (#8300). + +#### Drivers, query engine & analytics + +- **`$field` becomes a real cross-field comparison.** SQL push-down compiles it + to a column-to-column comparison (#5222); equality triples lower to + `{ $eq: ref }` (#7597); a field-to-field RLS rule is served on the analytics + path, with native SQL declining and routing to the engine (#7598); and the + comparand is refused on both SQL-lowering doors rather than being bound as the + comparison's *value*. The refusal names the author's own columns without + re-disclosing policy (#8220), and the two compared columns are withheld from a + cross-field refusal that would leak them (#7929, #7988). +- **MongoDB takes a structured `GroupByNode`**, answers `count` / `count_distinct`, + and buckets `dateGranularity` server-side with `supports.queryDateGranularity` + published (#7580); malformed `$between`, undeclared node-level `$`-keys and + `{ field: {} }` are refused (#5346, #5376). +- **An upsert whose `conflictKeys` have no backing unique index refuses + legibly** — in an ADR-0112 envelope rather than a raw `SqliteError` (#8445) — + on Postgres (#8567) and Turso as well as SQLite, with MySQL's behaviour + measured and pinned (#8592). +- **The first concurrent autonumber insert from two tenants no longer fails on + Postgres with `25P02`** (#8269), and the autonumber contract is stated: + unique and monotonic per scope, **not** gapless (#8283). +- **`ObjectQL.delete`'s by-id cascade is one unit of work** (#7413), `expand` + no longer silently no-ops when the nested `fields` omits `id` (#7537), and a + single-record update stops reporting the addressed row's own primary key as a + dropped field (#8093). + +#### Automation, jobs & the audit trail + +- **Automation runs record what triggered them, and keep it across a restart** + (#7533). A durable PAUSED run is visible to `listRuns` and run-detail after a + cold restart (#8050), its variable snapshot is readable on run-detail + (#7639), and a caught `try_catch` failure records what failed, how many + attempts ran and which node threw (#7546). +- **Scheduled and time-relative flows re-bind after a kernel rebuild** instead + of failing permanently, and a metadata reload reconciles declarative + connectors rather than no-op'ing against a stale registry (#7742). +- **Runs that did not do their work stop being audited as success** — a + timed-out job run records `timeout` (#7734) and a run that finishes without + doing its work records `degraded` (#5548). A durable `http` callout is + `unmeasured`, not `acted` (#7882), and a notify summary no longer reports a + delivery the delivery record dead-lettered (#7747). +- **The audit trail gets its missing writers.** Sign-in and sign-out are + recorded with the actor and the tenant (#8144); settings writes reach + `sys_audit_log` as `config_change` (#8145); the whole metadata lifecycle is + recorded, not only `save`; `publishPackageDrafts` writes the rows a batch + publish always owed; and a publish refused by a lock or a 409 leaves its own + audit row, outside the transaction so a rollback cannot take it (#8594, + #8400). A bearer-authenticated metadata write is attributed to the caller + rather than to `system` (#7749). + +#### New backend capabilities since rc.6 + +- **`OS_ARTIFACT_URL` — artifact-pinned boot** (#8368): boot a stack from a + published artifact by reference, with an SRI-style fragment pin. +- **`internal: true`** (#7728) — a field whose value is never returned on the + generic data path, with the write-response guarantee guarded as a property + rather than one class. +- **The `viewItems:` channel** (#5320, #8070) — a portable home for + non-container view artifacts in runtime-assembled manifests, with `views:` + tightened to the declared container-only contract. +- **The `security` service publishes the caller's resolved permission SETS** + (#7616), and `security/explain` gains a batch form — `recordIds: string[]`, + max 200, mutually exclusive with `recordId` (#8326). +- **Author-facing declarations that now hold**: `publicPicker` on + `FormFieldSchema` (#7467), `useGrouping` on `Field.number` (#7768), a declared + currency `precision` checked against ISO 4217 (#7918), `userActions.create` / + `.import` accepting the same CEL-predicate object form as `edit` / `delete` + (#7692), a `flows` surface on `TranslationBundle` for screen-flow wizard copy + (#7646), an optional `subCaption` on the dashboard widget translation node, + and an optional `object` on `GlobalFilterSchema` for i18n label resolution + (#7804). +- **`POST /auth/change-email` works** (#7735) — better-auth's `user.changeEmail` + is configured, with verification — and it notifies the **previous** address + without gating on it (#8019). +- **Verification harness**: `bootStack({ orgContext: true })` gives a harness + admin an organization-carrying execution context (#7762), and `--rls` runs one + probe persona per **declared position** so app-authored narrowing is actually + exercised (#7978) — after a fix for `--rls` reporting 0 HOLES over a probe + that could not reach the class it claimed to prove (#7685). +- **The multi-node gate can carry an admitted node count**, so a license cap + refuses the excess replicas rather than the whole cluster. + +#### New in Console — bundled objectui advanced `0cf8f0f70d10 → 665661ab0932` + +**Three pin moves**, released as **@objectstack/console 17.0.0** — the +enumerations are `console-6314e87f2d49`, `console-6d77acfe3125` and +`console-665661ab0932`: + +| Range | objectui commits | changesets releasing | +| :-- | --: | --: | +| `0cf8f0f70d10 → 6314e87f2d49` | 43 | 27 of 30 | +| `6314e87f2d49 → 6d77acfe3125` | 123 | 99 of 115 | +| `6d77acfe3125 → 665661ab0932` | 82 | 72 of 78 | + +`@objectstack/console` ships a frozen SPA build at the pin SHA and does not +forward objectui's type entrypoints, so **no new metadata migration is +registered for these moves** — each changeset records that verdict against +ADR-0087, and each lists by subject (not by count) the commits in its range +that carried no changeset of their own. + +The through-lines across the three: **the display locale finally reaches every +channel** — dates, currency, percentages, gantt and timeline labels, dashboard +and report measures, the null-value bucket, the Settings namespace screen, and +the organization/invitation console, closing the long tail where a `zh` session +read half its output in English. **Inspectors block Save on CEL parse faults**, +so a hook guard, action predicate, validation rule, page-block condition or +formatting rule that does not parse no longer saves and publishes as the live +definition. **The editable dashboard grid renders dataset-bound widgets — and +says so visibly when it cannot**, instead of a silent blank chart. And the +permission matrix models the server: it honors `allowRuntimeCreate`, names the +gate that tripped in its read-only badge, and models the artifact tier so a +Save cannot 403 on a code-declared set. + +Notable single fixes: impersonation takes effect at all on the data lane +(`set-auth-token`, #4467) with a standing banner and a loudly-failing exit; +cross-page "select all N matching" replays the host's real query or abstains +rather than fanning out unfiltered; lookup values link to the referenced +record; an `OBJECT_API_DISABLED` list renders an honest cannot-work state +instead of the empty state; `deleteView` removes every home a view has; +`exportOptions` follows the spec's object form with a legacy bare array lifted +by the bridge; and `crypto.randomUUID` is restored on insecure origins so list +views stop crashing on LAN IPs. + + +--- + diff --git a/content/docs/releases/v17/17.0.mdx b/content/docs/releases/v17/17.0.mdx new file mode 100644 index 0000000000..88c3635e9f --- /dev/null +++ b/content/docs/releases/v17/17.0.mdx @@ -0,0 +1,1917 @@ +--- +title: 17.0.0 +description: "The v17 major cut, published 2026-08-14: files become owned sys_file records with a governed download path, bulk export becomes its own privilege, the SDK drops 21 methods no server answered, and a datasource that cannot connect fails the boot." +--- + +## Highlights — 17.0.0 + +- **A file is a record now, not a blob in a column.** Media fields + (`file`/`image`/`avatar`/`video`/`audio`) store an opaque `sys_file` id; + the `{url, name, size, …}` object becomes the *read* (expanded) form. The + platform owns the bytes, so `accept` / `maxSize` are declarable **and + server-enforced**, downloads carry the real filename and content type, + and read authorization can be delegated to the owning object instead of + handed out as an unguessable URL. `os migrate files-to-references` performs + the conversion with a self-check and records a per-deployment flag. +- **Bulk export is its own privilege.** `allowExport` unset used to mean + "inherit read". It now means **denied**. Reading a record and taking a + machine-readable copy of the whole table are different acts — and + `viewAllRecords` / `modifyAllRecords` no longer confer export either. +- **The SDK reaches the surface that exists — and only that surface.** + 21 dead methods (plus the entire phantom `ai` namespace) are deleted, the + four ghost route tables that underwrote them go with them, and 40+ genuinely + mounted routes the SDK could never reach are now typed: actions, keys, + share links, security, package lifecycle, reports, approvals, record shares, + sharing rules, search, `ai.chat`/`ai.chatStream`/`ai.conversations.*`, + `ai.agents.*`, `ai.pendingActions.*`. A route-ledger conformance gate runs + in both directions, so the two sets cannot drift apart again. +- **An approver can choose the next approver.** Approval nodes gain + `expression` approvers (CEL over `current.*` / `trigger.*` / `vars.*`), + a node-level `onEmptyApprovers` policy (`admin_rescue` / `fail` / + `auto_approve`), and declared **decision outputs** that resume the run as + `.` variables — so "the lead reviewer picks which departments + co-review" is a declaration, not a record-field detour. +- **A broken datasource is loud at boot.** A datasource that objects bind to + must connect or the boot fails; `objectql.init()` refuses to start on a dead + data driver; `/ready` answers 503 when one stops answering; and the `default` + datasource is now a declaration travelling the same connect-and-verdict path + as every other one, instead of a second copy of the policy. +- **An approval request is visible to its participants.** `getRequest` / + `listRequests` / `countRequests` applied only the tenant half of the + visibility rule, so any authenticated user could read any request in their + tenant — payload snapshot, decision history, and attachments. +- **A sharing rule with no criteria shares nothing, and a disabled RLS policy + is disabled.** Both were documented contracts whose real behaviour was wider: + a rule stored without criteria evaluated as *every record of the object* + (reachable by a typo through three unvalidated write paths, #3896), and an + RLS policy switched off with `enabled: false` kept contributing its + OR-branch grant. Both now fail closed — found by re-verifying every + security-subset claim in the spec liveness ledger against the actual call + graph, a sweep that also removed the void RLS `priority` knob and the + never-wired plugin sandboxing/integrity config, and left two new CI gates + behind the whole class: a permissive empty state must be classified on + purpose, and the security entries now carry runtime proofs. +- **Node.js 22 is the floor.** `engines.node` said `>=18` across all 50 + manifests while CI, the release pipeline and every shipped Docker image ran + 22. The promise now matches the evidence. +- **The dead-metadata sweep continues.** GraphQL, `PortalSchema`, `AuditConfig`, + the capabilities-descriptor cluster, `FeatureFlagSchema`, `SkillSchema.permissions`, + `tool.requiresConfirmation`, `agent.tools[]`, `object.enable.trash`/`mru`, + report `aria`/`performance`, `DEFAULT_DISPATCHER_ROUTES`, the four inert tool + authoring keys (`category`/`permissions`/`active`/`builtIn`), the close-out + sweep across action/flow/view/dashboard/agent/skill (fourteen more inert + keys, `flow.active` and `agent.knowledge` among them) and the last three + deprecated authorable aliases are removed — each one had been parsed and + ignored. +- **One name, one declaration.** Seventeen clusters of `@objectstack/spec` + exports resolved to *different declarations* depending on which subpath you + imported from — `Session`, `EventSchema`, `RetryPolicy`, `FieldMapping`, + `HttpMethod`, `TenantPlan`, `PackageDependency` and eleven more. An + auto-import picked by name, the shapes overlapped enough to compile, and the + mistake surfaced later as an `undefined` or a silently stripped key. Each + cluster is now judged and resolved — one declaration keeps the bare name, the + other is renamed, re-exported or deleted — and a symbol-identity ratchet + fails the build if a name ever forks again. +- **The authorable surface is closed.** Every authorable metadata type now + rejects unknown keys with a named prescription, on the parse path and not + only in `create()`. The #4001 campaign that started with one object schema + ends this line at zero: object, field, view, dashboard, action, agent, page, + mapping, translation, the six validation variants, the Studio surface and the + registered types behind them. + + + +## 17.0.0 in detail + +### Breaking changes & migration + +#### Node.js 22 is the supported floor (#3825) + +`engines.node` moves from `>=18.0.0` to `>=22.0.0` across all 50 published +manifests. Node 18 reached end-of-life on 2025-04-30 and Node 20 on 2026-04-30, +so the old range promised two runtimes nobody patches and nothing in the repo +verifies. + +```bash +nvm install 22 && nvm use 22 +``` + +**On Node 22 or newer, nothing changes** — Node 24 and 26 both satisfy the +range. npm and pnpm surface an unsatisfied `engines` as an `EBADENGINE` +warning rather than a hard failure, so an existing install will not break the +instant you upgrade; the package is simply no longer tested there. If your CI +pins Node, pin it to 22 as well. + +#### `allowExport` is opt-in — export no longer inherits read (#3544, #3710) + +| | before | after | +|---|---|---| +| `allowExport` unset | export **allowed** (inherited read) | export **denied** | +| `allowExport: false` | denied | denied (unchanged) | +| `allowExport: true` | allowed | allowed (unchanged) | + +The one-line fix — add the grant to the object entry (or the `'*'` wildcard) of +every permission set whose holders should keep exporting: + +```ts +objects: { + deal: { allowRead: true, allowExport: true }, // ← add the grant +} +``` + +- **Package-shipped sets are re-seeded on upgrade**, so `admin_full_access` and + `organization_admin` carry `allowExport: true` for you. + **Environment-authored sets are not** — edit any custom set whose users + export. `member_default` deliberately does *not* carry the grant, so ordinary + authenticated users lose export until an admin grants it. That is the point of + the flip. +- **Merge is most-permissive**, exactly like the CRUD bits: any set granting + `true` grants export; `false` and unset are the same outcome. +- **`viewAllRecords` / `modifyAllRecords` no longer imply export.** Separating + "may see all data" from "may take a bulk copy" is the segregation-of-duties + case the axis exists for. +- A set carrying `allowExport` is now **high-privilege**, so it cannot be bound + to the `everyone` / `guest` audience anchors — otherwise the opt-in was + defeatable by binding an exporting set to an anonymous anchor. + +Read/CRUD/RLS/FLS/sharing are untouched, and reports are covered by the same +axis. + +#### `enable.apiMethods` shrinks to the six primitives (#3543) + +The authorable enum is now exactly `get`, `list`, `create`, `update`, `delete`, +`bulk`. The eight legacy values are **derived** effective operations resolved by +the server's single derivation table, not things you declare. + +| FROM (legacy) | TO (primitives) | why | +|---|---|---| +| `upsert` | `create`, `update` | upsert ⊆ create ∧ update | +| `import` | `create`, `update` | import ⊆ create ∨ update | +| `export` | `list` | export ⊆ list | +| `aggregate` | `list` | aggregate ⊆ list | +| `search` | `list` | search ⊆ list ∧ `searchable` | +| `history` | `get` | history ⊆ get ∧ `trackHistory` | +| `restore` | *(delete the value)* | never derives — `enable.trash` retired | +| `purge` | *(delete the value)* | never derives — `enable.trash` retired | + +Replace each legacy value with the primitives it derives from, de-duplicate, and +if the result names all six, **delete the `apiMethods` key entirely** — that is +equivalent to default-open and it tracks future primitives. + +```bash +node scripts/codemod/apimethods-legacy-to-primitives.mjs +``` + +The codemod is a **reporter**: it scans, prints the exact replacement per site, +and flags whitelists the mapping would *widen* so every edit stays reviewable. + +**Stored metadata keeps parsing.** A stored legacy value is not a parse error — +`stripLegacyApiMethods` removes it with a FROM→TO warning. Stripping only ever +*narrows*. Watch one cliff: a whitelist of **only** legacy values (e.g. +`['upsert']`) strips to `[]`, which is **deny-all** — the object's API closes +rather than widening. + +#### An action must be declared to be invocable, and is identified by `name` (ADR-0110, #3935) + +Two halves of the `/api/v1/actions/:object/:action` contract were broken in +complementary ways, which is why neither surfaced: the route resolved the +**declaration** from the URL segment as a `name`, but dispatched the +**handler** using that same segment as a registry key. For a target-bound +action (`{ name: 'complete_task', target: 'completeTask' }`) those differ — so +the documented `curl .../todo_task/complete_task` resolved the declaration and +then 404ed, while the Console's `target`-addressed call dispatched fine and +resolved **no** declaration, silently skipping the ADR-0066 D4 capability gate +and the ADR-0104 param contract. + +Three changes land together: + +- **Identity is `name`.** The URL, MCP `run_action`, and every future surface + identify an action by its declarative `name`. `target` is a binding + expression — polymorphic per type, `${param.X}`-interpolatable, and legally + non-unique — so it never identifies anything. The server derives the handler + key from the declaration it resolved, using the rotation the MCP bridge + already used. The Console posts `name` (objectui ships in lockstep). +- **An undeclared handler is refused.** `engine.registerAction` with no + matching declaration has no `requiredPermissions` to enforce and no param + contract to check, yet executes with system privileges. It now returns 404 + naming the `defineAction` to add. Deleting a declaration used to *remove* an + action's gate while leaving it callable; removal now narrows. +- **An unreachable metadata plane refuses instead of degrading.** A loader + failure used to be indistinguishable from "no declaration", so an outage + silently ungated every action it could not see. That is now a **503** — the + same posture as the datasource entry below. + +**Migration:** boot logs list every registered-but-undeclared handler under +`[action-governance]`, alongside declared script actions bound to no handler. +Declare each one with `defineAction`, or drop the registration if nothing should +invoke it over HTTP. **There is no opt-out flag** — a switch that ran an +ungoverned handler would be the same fail-open this change closes, and a sweep +of the platform, every package and every example found zero undeclared handlers, +so it would have shipped a way to reopen the gate for a case nobody has +observed. The failure is bounded: the app still boots and every declared action +still works; only an undeclared one returns 404, and it names the `defineAction` +to add. + +Apps whose actions are all declared — anything with working Console buttons — +need no changes, other than gaining enforcement of the `requiredPermissions` +they already declared. Callers that hard-coded a `target` in an action URL +switch to the action's `name`. + +#### An action's declared params are enforced at dispatch (ADR-0104 D2, #3438) + +An action's `params[]` (`required`, `options`, `multiple`, `reference`) was a +complete contract that informed **only the client dialog**. The server passed +`reqBody.params` to the handler unvalidated on both the REST and MCP paths, so +a wrong bag could return a success envelope while the handler quietly ignored +it — the #3405 shape, one layer out. It is now checked before the handler runs; +a violation is `400 VALIDATION_FAILED` (REST) or a thrown error (MCP). + +| Violation | Example | +|---|---| +| missing `required` param | declared `p_text` absent from the bag | +| value outside `options` | `p_priority: 'NOT_AN_OPTION'` | +| `multiple` shape | a bare string where an array is declared | +| `reference` shape | a non-id where `reference: 'sys_user'` is declared | +| undeclared key | `bogus: 123` | + +```diff +- OS_ACTION_PARAMS_STRICT_ENABLED=1 # removed — enforcement is the default ++ OS_ALLOW_LAX_ACTION_PARAMS=1 # escape hatch: warn and pass, as before +``` + +**Only already-wrong calls break**, and each rejection names the offending param +and the declared list, so the fix is one edit at the call site. Actions +declaring no `params` are untouched — there is nothing to validate against — +and the dispatcher's own `recordId` / `objectName` are allowlisted, so keys +dispatch merges in are never unknown-key errors. If an integration you cannot +reach in time is affected, `OS_ALLOW_LAX_ACTION_PARAMS=1` restores the old +pass-through in one restart; the violation still logs once per action, so the +drift stays visible rather than becoming invisible again. + +The RC line carried this warn-first behind an `OS_ACTION_PARAMS_STRICT_ENABLED` +opt-in. **That variable does not exist in 17.0.** The window was closed on +purpose rather than deferred to 18.0: what a violation strands is a *caller*, +not data — no stored row becomes unwritable, the party who sees the error is +the party who can fix it, and the hatch makes it reversible. Deferring would +have charged every deployment a second upgrade ceremony to postpone a break +that costs one edited call. The value-shape half of the same ADR went the other +way for the opposite reason: it rejects on the basis of data already at rest, +so it stays gated per deployment (see *Files become platform records* below). + +#### An action param's picker target is `reference`, and only `reference` (objectui#3203) + +`ActionParam` in `@object-ui/types` no longer declares the nine resolved-side +picker keys — `referenceTo`, `displayField`, `idField`, `descriptionField`, +`titleFormat`, `lookupColumns`, `lookupFilters`, `lookupPageSize`, `dependsOn`. +Two rewrites cover everything authored against them: + +```diff +- { name: 'account_id', type: 'lookup', referenceTo: 'account' } ++ { name: 'account_id', type: 'lookup', reference: 'account' } +``` + +- **The inline picker target** — spell it `reference`, as above. It is the only + authorable way to name an inline `lookup` / `master_detail` param's target + object, and `ActionParamSchema` refuses a targetless inline picker at parse + time rather than letting it degrade into a paste-a-UUID text box (#3405). +- **The other eight** — make the param **field-backed** (`{ field: 'account_id' }`) + and the whole picker group is inherited rather than restated: `displayField`, + `descriptionField`, `lookupColumns`, `lookupFilters`, `lookupPageSize` and + `dependsOn` are `FieldSchema` keys, resolved at runtime from the referenced + field's own metadata. `idField` and `titleFormat` were never authorable on + either side — the picker resolves record identity itself, and a candidate's + label comes from the referenced object's `nameField` (ADR-0079). + +**This removes a compile-time illusion, not a capability.** None of the nine was +ever storable: `ActionParamSchema` is `.strict()`, its authorable key list +carries `reference` and not `referenceTo`, and its alias table names +`referenceto → reference` by hand (`packages/spec/src/ui/action.zod.ts`) — so an +authored `referenceTo` has always been a hard parse rejection on the server. +Only `tsc` waved it through, against objectui's public type, which moved the +failure from the authoring keystroke out to publish time. `ActionParam` is now +derived from the spec schema (`Omit, 'type'>`), +so the authoring type and the parser can no longer disagree about a spelling, +and `resolveActionParams()` names any resolved-only key it still meets in a +dev-mode warning carrying the prescription above — which covers the params +authored in plain JS or JSON, where `tsc` never looks. + +#### A flow run with no trigger user may not touch data (#3760) + +An effective `runAs: 'user'` run that resolved **no trigger user** used to +execute its data nodes *unscoped* — it presented no principal, the data security +middleware skips when there is no principal, and the run then read and wrote +every row. `runAs: 'user'` is an access-**narrowing** declaration; failing to +resolve it must never resolve to a grant. It now refuses with +`UnscopedRunDataAccessError`. + +This was never really a schedules problem. `isSystem` does not suppress trigger +dispatch — only `skipTriggers` does — so every plugin/service system write, the +approvals status mirror, and a `runAs: 'system'` flow's own data node dispatched +record-change flows with `userId: undefined`. Unprivileged input reached that +path routinely. + +**Migration:** a flow that reacts to system writes and must act beyond one +user's grants declares `runAs: 'system'`, making the elevation explicit and +audit-attributable. Otherwise ensure the trigger supplies a user. Flows that +touch no data are unaffected, the engine warns at run *setup* before any node +executes, and the originating write still succeeds because the trigger already +swallows flow errors. + +#### The last three deprecated authorable aliases are removed (#3855) + +| Removed | Use instead | Value shape | +|---|---|---| +| `action.execute` | `action.target` | unchanged | +| `field.conditionalRequired` | `field.requiredWhen` | unchanged | +| `agent.knowledge.topics` | ~~`agent.knowledge.sources`~~ → the whole `knowledge` block was later removed (#3896 close-out — see the dead-cluster table) | — | + +All three are pure key renames — each alias was already lowered into its +canonical key at parse time, so what shrinks is the authorable surface, not the +semantics. + +```bash +os migrate meta --from +``` + +The renames are registered as protocol-17 chain steps, so one pass applies all +three plus every earlier step you skipped. Manual alternative: rename the key. + +```diff +- actions: [{ name: 'convert', type: 'script', execute: 'convertHandler' }] ++ actions: [{ name: 'convert', type: 'script', target: 'convertHandler' }] + +- fields: { due_date: { type: 'date', conditionalRequired: 'record.stage == "closed"' } } ++ fields: { due_date: { type: 'date', requiredWhen: 'record.stage == "closed"' } } + +- knowledge: { topics: ['faq', 'policies'], indexes: ['docs'] } ++ (delete the block — `agent.knowledge` was removed outright in the #3896 ++ close-out: declaring sources never scoped retrieval) +``` + +Each removed key is **tombstoned** as `never` rather than simply deleted, so +writing it is a `tsc` error at the authoring site *and* a parse error carrying +the rename — none of these schemas is `.strict()`, so a plain deletion would +have made Zod silently strip the key and the setting would have quietly stopped +taking effect. + +`lintDeprecatedAliases` and its rule-id exports are removed with them: that pass +existed to warn when an author declared both an alias and its canonical key, +which the parse now rejects outright. Delete the import; there is no replacement +because the condition can no longer occur. + +#### Seven flow-node config key aliases graduate into the conversion layer (#3796) + +| Node type(s) | Deprecated | Use instead | +|---|---|---| +| `get_record` / `create_record` / `update_record` / `delete_record` | `config.object` | `config.objectName` | +| `notify` | `config.to` / `config.subject` / `config.body` / `config.url` | `config.recipients` / `config.title` / `config.message` / `config.actionUrl` | +| `script` | `config.functionName` / `config.input` | `config.function` / `config.inputs` | + +All are pure key renames with unchanged values. Unlike the three tombstoned +aliases above, these **cannot** be rejected in the schema — +`FlowNodeSchema.config` is an unconstrained record — so they keep a load-path +acceptance window instead: a stored flow authored with an alias keeps loading +through protocol 17, rewritten to the canonical key at load (including +`AutomationEngine.registerFlow` rehydration) with a `ConversionNotice`; the +window retires in 18. The executors read canonical keys only, and the +`readAliasedConfig` executor shim (which covered `object` → `objectName` and +warned at run time) is deleted — the conversion layer is now the single seam +that declares, converts, and retires flow-config aliases. + +`os migrate meta --from ` rewrites all seven in your +source; or rename the keys by hand. `actionUrl` is the deliberate canonical of +its pair: the downstream chain already uses that name +(`sys_notification.action_url`, the channel contract, the REST notification +model), while `url` platform-wide means an HTTP endpoint to call (`http` node, +webhooks). The singular `input` on `map` / `subflow` / `connector_action` is +those nodes' own canonical key and is untouched. + +#### Authorable schemas reject unknown keys (#4001) + +Zod's default is `.strip`: a key a schema does not declare is silently +discarded and the instance keeps parsing. On an authorable surface that is the +worst failure mode — the author (human or AI) gets a success envelope and +ships metadata that quietly ignores their config; that is exactly how a +correctly intended action-param `reference: 'sys_user'` degraded into a +paste-a-UUID text box (#3405). 17.0 extends #3746's strictness from that one +schema to the two highest-risk authorable surfaces, per the triage in +`docs/audits/2026-07-unknown-key-strictness-ledger.md`: + +- **Permission sets** — `PermissionSetSchema`, `ObjectPermissionSchema`, + `FieldPermissionSchema`, `AdminScopeSchema`. A silently dropped key on the + capability container meant the author believed a grant or restriction was in + place that the runtime never saw. The response-side + `EffectiveObjectPermissionSchema` stays wire-tolerant. +- **Flows** — `FlowSchema`, `FlowNodeSchema`, `FlowEdgeSchema`, + `FlowVariableSchema`. A node's `config` stays an **open** record: it is + per-node-type, owned by the executor's `configSchema` and the conversion + layer (see the alias table above). +- **RLS policies** — `RowLevelSecurityPolicySchema`. A silently dropped key + here meant a row-level restriction the author wrote was never compiled into + the filter (the pre-ADR-0090 D3 vocabulary → `positions`, + `withCheck` → `check`, `condition`/`filter`/`where` → `using`). The runtime + evaluation shapes stay tolerant. +- **Sharing rules** — the rule, its criteria extension, and the `sharedWith` + recipient (`criteria` → `condition`, `access` → `accessLevel`, + `recipient` → `sharedWith`; `ownedBy` carries the removed owner-type-rule + prescription). +- **Positions** — `PositionSchema`, including the guidance that + `permissionSets`/`users` are runtime bindings and `parent` has no meaning on + a deliberately flat position (ADR-0090 D3). Position also gains the + `protection` block and ADR-0010 runtime envelope every sibling registered + type already declared. +- **The app shell** — `AppSchema`, its branding / area / context-selector / + contribution blocks, and the whole navigation tree. The nav-item union is + now DISCRIMINATED on `type`, so one unknown key yields one precise issue + against the branch you wrote (at an exact path through nested `children`) + rather than a nine-branch `invalid_union` wall. Per-target payloads + (`params` on page / component / action items) stay open. The gate's first + catch here was in the platform's own Account app: three navigation groups + declared `defaultOpen` — never a schema key — and so shipped collapsed + while their author believed they opened by default (`expanded` is the key). +- **Approval nodes** — all four authoring schemas (node config, approver, + escalation, decision-output). Process-era keys carry the ADR-0019 re-home + map (`steps` → successive approval nodes, `entryCriteria` → the entering + edge's condition, `onApprove`/`onReject` → the `approve`/`reject` + out-edges, `rejectionBehavior` → a declared back-edge). The published JSON + schema carries `additionalProperties: false` into the Studio form and + `registerFlow()` config validation, so a mis-keyed approval `config` is + rejected at registration too. +- **Hooks** — `HookSchema`, its `retryPolicy`, and both hook-body branches + (`expression` and `js`). A body was the worst place to lose a key quietly: a + misspelt `capabilities` stripped to the empty default and the sandbox then + threw at *invocation* time, on whichever code path first touched `ctx.api` — + far from the typo. A misspelt `timeoutMs`/`memoryMb` silently downgraded the + body to the enclosing hook's looser limits. Two near-miss spellings now carry + aliases because they are genuinely easy to cross: hook-level `timeout` vs + body-level `timeoutMs`, and hook `retryPolicy.backoffMs` vs datasource + `retryPolicy.baseDelayMs`. `HookContextSchema` — the runtime shape the engine + hands your handler — stays tolerant and always will. +- **Datasources** — `DatasourceSchema` with its `pool` / `healthCheck` / `ssl` / + `retryPolicy` blocks, the ADR-0015 `external` federation settings and their + `validation` policy, `DatasourceCapabilities`, and `DriverDefinitionSchema`. + `config` stays an **open** record: its shape is per-driver. What it no longer + is, is unvalidated — #4410 wired the per-driver schemas + (`PostgresConfigSchema` and siblings) into `DatasourceSchema`'s refinement, so + a misspelling one level *down* is now rejected with the canonical key named. + An earlier version of this note said the driver's own `configSchema` did that, + which was wrong for two releases: the field existed, nothing read it. That + openness is why the + top level had to close — a connection key written one level too high (`host` + next to `driver` instead of inside `config`) was stripped, and the datasource + then connected on driver defaults rather than failing. Those keys now + prescribe the move into `config`; a top-level `password` is instead pointed at + `external.credentialsRef`, because relocating an inlined secret is not the fix. + A dropped key in `capabilities` was quieter still — though not for the reason + this note used to give. It claimed an unregistered capability "reads as + `false`, so the engine stopped pushing that work down to the driver and + recomputed it in memory", which was never true: the #4487 liveness audit + found the whole `capabilities` block has no reader at all. The engine gates + pushdown on the runtime driver's own `supports.*` object, a different + mechanism with a non-overlapping vocabulary. Every key in the block is `dead` + in `liveness/datasource.json`, and `capabilities.readOnly` is the one to know + about: it reads as a safety switch and gates nothing — `external.allowWrites: + false` is the enforced write gate. + +One clarification, since these flips are easy to over-read: making a schema +strict does **not** change its published JSON Schema. `build-schemas.ts` +converts with `io: 'output'`, and in output mode zod emits +`additionalProperties: false` for a `.strip()` object too — the post-parse shape +genuinely has no extra keys. So the JSON Schema was already advertising +`additionalProperties: false` while the zod parse quietly accepted and discarded +unknown keys. These flips align the parse with the contract that was already +published; they do not widen it. + +Every rejection is written to be self-fixing: it names the offending key and, +where recognisable, the canonical spelling (`steps` → `nodes`, edge +`from`/`to` → `source`/`target`, `read` → `allowRead`, `tabs` → +`tabPermissions`), a wrong-layer pointer (a flow-level `objectName` belongs on +the start node's `config`; `apiOperations` is response-side only), or a +retired-key tombstone (`contextVariables` → ADR-0105 D11's `rlsMembership` +resolvers; `isProfile` → `isDefault`, ADR-0090 D2). + +Migration is by construction behavior-preserving: any key now rejected was +previously stripped, so it never had a runtime effect — remove it or rename it +to the canonical key the error suggests; nothing about a working app changes. + +The gate paid for itself immediately by exposing keys the platform *writes* +but the spec could not express, so `PermissionSetSchema` gains three: the +**`description`** shown in Setup (persisted to +`sys_permission_set.description`), the author-facing **`protection`** block, +and the ADR-0010 **runtime protection envelope** (`_lock`, `_packageId`, +`_provenance`, …) that every sibling metadata type already carried — without +it, a package-owned permission set could not round-trip through an +environment overlay. + +#### `agent.tools[]` is removed — capability comes from skills (ADR-0109, #3820) + +ADR-0064's invariant is "an agent's tool set is the union of its +surface-compatible skills' tools; nothing falls through to the global registry". +The inline `tools` slot was the one seam that broke it — the runtime resolved +`agent.tools[].name` against the **full** registry with no surface check, so an +`ask`-surface agent could name an authoring tool and get it. `AIToolSchema` and +the `AITool` type go with it. + +**Migration:** attach capability through `skills`. An agent authoring `tools` is +not a parse error — the key is tombstoned and stripped — so existing stacks keep +parsing, but the slot no longer does anything. + +Two lint changes ride along: `validate-ai-tool-references` now models AI +exposure the way the runtime does (a tool materialises only when the action +declares `ai.exposed: true` + `ai.description` **and** has a headless path — +`url`/`modal`/`form` are UI-only), and a new `validate-ai-agent-authoring` rule +warns on a stack declaring `stack.agents`, which the runtime has filtered from +the catalog since ADR-0063. + +#### Error codes are one SCREAMING_SNAKE vocabulary, and `error.code` is a closed set (ADR-0112, #3841) + +The platform shipped two live error-code dialects: `StandardErrorCode` declared +lowercase `snake_case` members while 139 codes on the wire were SCREAMING_SNAKE, +and `ApiErrorSchema.code` was a bare `z.string()`, so nothing validated either. +Now there is one vocabulary — the standard catalog plus `ERROR_CODE_LEDGER` for +service-specific codes — and `error.code` validates against their union, so an +unregistered code fails parse instead of quietly becoming a new dialect. + +**Wire-visible.** Codes change spelling on every surface that spoke lowercase. +Generic conditions collapse onto the standard catalog rather than keeping a +synonym: `unauthorized`/`unauthenticated` → `UNAUTHENTICATED`, `forbidden` → +`PERMISSION_DENIED`, `not_found` → `RESOURCE_NOT_FOUND`, `internal` → +`INTERNAL_ERROR`, `unavailable` → `SERVICE_UNAVAILABLE`, `not_supported` → +`NOT_IMPLEMENTED`, `bad_request` → `INVALID_REQUEST` (a status name is not a +semantic code). Domain conditions get a registered SCREAMING code. + +**Migration:** branch on `error.code` **values**, and do not pattern-match their +case. A code branch that misses fails *silently* — nothing throws, the affordance +it guards just disappears — which is how eleven console branches broke without a +single test failing (objectui#2977). If you support servers on both sides of the +upgrade, compare case-insensitively; that is what the console does. + +Four routes also stop putting a code in the message slot: the webhook redeliver +route, the API-trigger webhook and two `rest` routes answered +`{ success: false, error: '', message }`. They now emit +`error: { code, message }`, so a client reading `body.error` as a string on those +routes must read `body.error.code`. + +Not swept, deliberately: `sys_metadata_audit.code` (persisted audit history, and +the column also holds non-errors like `ok`), diagnostics records that ship inside +a 200, field-level codes (see below), and the CLI's `--json` output contract. + +#### Field-level error codes are their own closed catalog, and `fieldErrors` becomes `fields` (ADR-0114, #3977) + +The per-field `code` inside `fields[]` is a **different** vocabulary from +`error.code`, and it stays lowercase on purpose: a top-level code names the +condition the *request* hit, while a field-level code names the *constraint* the +value violated — and constraints are declared in the metadata's own snake_case, +so `max_length` the code and `max_length: 50` the property are the same word. +`FieldErrorCode` closes that set (27 members) and `FieldErrorSchema.code` +validates against it, where it used to be `z.string()`. + +**`EnhancedApiError.fieldErrors` is renamed to `fields`** — the name every +producer already emitted. The old key was declared and emitted by nobody, so a +reader keying on it was reading a field no server sent. It is tombstoned rather +than deleted: writing it now fails with the rename instead of parsing clean and +losing the array. + +**Wire-visible.** Routes that validate with Zod stopped leaking Zod's issue codes: +`too_small` now becomes `min_length` / `min_value` / `min_items` depending on what +was too small, `unrecognized_keys` becomes `unknown_field`, and — the one that was +a real bug — a **missing** required property now reports `required` instead of the +`invalid_type` Zod uses for it, so a form marks it as missing rather than +wrong-typed. + +**Migration:** read `error.fields`; branch on the catalog's lowercase codes for +per-field handling, and show `message` to the user rather than the code. + +#### Approval requests are visible to participants, not the whole tenant (#3590) + +`getRequest` / `listRequests` / `countRequests` query with `SYSTEM_CTX` to +bypass RLS because approver visibility spans identity forms RLS cannot model — +but only the **tenant** half of that rule was ever written. Any authenticated +user could read any approval request in their tenant, including its payload +snapshot, its full decision history and its attachments. `approverId` on +`listRequests` is a *filter*, not authorization: omitting it returned the whole +tenant. Callers that relied on the wide read must now be participants (or hold +the admin override). + +#### Sharing: the `full` access level is gone, and the recipient enum matches the runtime (#3865, #1878) + +- **`accessLevel: 'full'` is removed.** It advertised delete/transfer/share and + granted plain `edit`. Stored rules convert to `'edit'` — the access they + actually had — through a protocol-17 conversion, so nothing silently gains or + loses reach. +- **`sharedWith.type: 'group'` → `'team'`** (wire rename), matching the ADR-0090 + D3 vocabulary the runtime already expands via `sys_team` / `sys_team_member`. + The old spelling was silently skipped at seed time. +- **`business_unit` is added** to the authoring enum — exactly one business + unit's members, no subtree (use `unit_and_subordinates` for the subtree). The + runtime already enforced it; only the enum omitted it. +- `guest` and the owner-type rules are pruned. Every authorable recipient and + rule type is now enforced. + +#### Sharing rules: an empty criteria shares nothing (#3896) + +A rule stored without criteria — missing, `null`, `{}`, unparsable, or a +misspelled key (`criterias`) — used to evaluate as `find(object, { filter: {} })` +under the system context: **every record of the object, granted to the +recipient**, up to the evaluator's 5000-record window. Three write paths +accepted that shape without validation: `POST /api/v1/sharing/rules`, a direct +`sys_sharing_rule` insert (what authoring in Setup issues), and the seed +bootstrap's own match-all branch. The field description even advertised it — +*"leave empty to share every record"* — which was never a feature, only a name +for the bug. + +Now, in three layers: + +- **`defineRule` rejects** a match-all criteria with `VALIDATION_FAILED`. +- **The evaluator matches nothing** for such a rule and logs why. This is the + layer that matters for already-deployed tenants: a row stored before this + gate **under-shares instead of over-sharing, and the next reconcile revokes + the grants it had materialised**. No data migration is needed. +- **A `sys_sharing_rule` insert fails field-level** (`fields[].field = + 'criteria_json'`), so Setup marks the Criteria input instead of saving a + rule that silently does nothing. The Console builder says so before you + save (objectui#2962) and renders the server's reason on the field + (objectui#2966). + +There is no "share every record" sharing rule: object-wide read is the +object's organization-wide default (`sharingModel`). A rule that relied on the +empty shape must state its predicate, or the object should use `sharingModel`. + +#### RLS: `enabled` is enforced, `priority` is removed (#3980, #3990) + +- **`enabled: false` now actually disables a policy.** The schema always said + *"Disabled policies are not evaluated"*, but nothing read the property — and + because applicable policies OR-combine (any match allows access), a policy an + admin switched off **kept granting row access**. The gate is at the single + choke point both the find and analytics paths flow through; `enabled` absent + stays active, so no stored policy changes behaviour. Access-narrowing only — + but re-check any policy you set `enabled: false` on expecting no effect, + because until now it had none. +- **`priority` is removed.** It promised "conflict resolution" that cannot + exist: with OR-combination there is never a conflict to order, and evaluation + order cannot change an outcome. Nothing ever read it. Authored values are + tombstoned with a fix-it prescription; `os migrate meta` deletes the key + mechanically, and policy outcomes are identical with or without it. + +#### `required` is a write contract; the column constraint is `storage.notNull` (ADR-0113) + +`field.required` used to mean three things through one knob: the write check, +the physical `NOT NULL`, and the drift expectation. Bound together, tightening +any invariant on a deployed object was a destructive migration blocked by the +very legacy nulls that motivated it — so real invariants ended up as imperative +guards (three of them for `sys_sharing_rule.criteria_json`) or didn't happen. +Split in v17: + +- **`required: true`** is the write-time contract, uniformly: an insert must + provide a non-null value, an update may not null it out — a PATCH clearing a + required field is now rejected (it silently passed before) — and legacy null + rows rest: a write that doesn't touch the field never blocks. The column + stays nullable. +- **`storage: { notNull: true }`** is the explicit physical constraint. It owns + the `NOT NULL` DDL and the destructive-migration ceremony (backfill first). + Declaring it at field creation is free; declaring it over existing nulls is + loud. +- **`requiredWhen` inherits the same non-regression rule**: a write that flips + the condition true without providing the field is rejected (it *creates* the + violation), while a row violating since before the rule tightened no longer + locks out unrelated edits. `storage.notNull` × `requiredWhen` is rejected at + parse — a conditional contract cannot be an unconditional constraint. +- **Pre-17 sources keep their exact meaning**: `os migrate meta` stamps + `storage: { notNull: true }` onto every previously-required field + (`field-required-notnull-explicit`) — under the old semantics that column + *was* created `NOT NULL`, so the rewrite writes down what the text already + meant. Migration-chain-only: the loader never guesses. +- **Drift**: nullability now compares against `storage.notNull`. A column + stricter than its declaration is `needs_confirm` (ratify or relax — dev + auto-reconcile no longer silently strips a stray `NOT NULL`), and silent when + the field is `required` (the write gate makes the constraint unreachable). + +#### Membership grade is not a capability channel (ADR-0108, #3723) + +`sys_member.role` answers "what is your standing in this organization", not +"what may you do" — but `resolve-authz-context` projected **every** value stored +there into `current_user.positions`, so business capability handed out through +the membership grade *was* capability — granted with none of the position +system's controls (no `granted_by`, no effective dating, no ADR-0091 checks). +The vocabulary is now closed. Move business capability to positions +(`sys_user_position`). + +#### better-auth 1.7.0-rc.2 — account identity restructuring + +better-auth renamed `account.accountId` to `account.providerAccountId` and added +a **required** `account.issuer`; sign-in now resolves accounts by +`(issuer, providerAccountId)`. + +- FROM `fields: { accountId: 'account_id' }` → TO + `fields: { issuer: 'issuer', providerAccountId: 'account_id' }`. The provider + account id keeps its `account_id` column; `sys_account` gains an `issuer` + column. +- FROM `internalAdapter.createAccount({ providerId, accountId, … })` → TO + `createAccount({ providerId, issuer, providerAccountId, … })`. A local + password account carries better-auth's own `local:credential` issuer. +- FROM `client.auth.accounts.unlink({ providerId, accountId })` → TO + `unlink({ accountId })`, where `accountId` is the account **row** id from + `accounts.list()`. That listing now returns `issuer` + `providerAccountId` in + place of `accountId`. + +**Existing deployments:** rows written before 1.7 have no issuer and are +invisible to sign-in until stamped. The auth plugin runs an idempotent +boot-time backfill that derives what it can — `local:credential` for password +accounts, `local:oauth:` for configured social providers, and the +registered IdP's real `iss` from `sys_sso_provider` for federated ones. Accounts +from a federated IdP that is **no longer registered** cannot be derived; they +are logged with their provider id and row count rather than guessed, and those +users cannot sign in through that provider until the row is stamped or removed +so a fresh login re-links it. + +`@better-auth/scim` deliberately stays on 1.7.0-rc.1 — rc.2 replaces its whole +model, which is a feature migration rather than a version bump. + +#### Dead SDK surface deleted (#3612, #3702, #3718, #3731) + +Five client families built URLs that exist on **no** server surface, so every +call was a guaranteed 404. They are removed, along with the four unconsumed +`DEFAULT_*_ROUTES` tables that underwrote them: + +- `client.permissions.*` (check, getObjectPermissions, getEffectivePermissions) +- `client.realtime.*` — `service-realtime` registers zero HTTP routes +- `client.workflow.*` (getConfig, getState, transition) +- `client.views.*` CRUD — there is no `/ui/views` route anywhere +- `client.notifications` device/preference helpers — the ADR-0012 server side + was never built +- `client.ai.{nlq,suggest,insights}` — the whole namespace, rebuilt below +- `client.projects.listTemplates()` — targeted `GET /api/v1/cloud/templates`, + mounted by nothing +- `os environments create --template` and its `template_id` body field — the + flag was accepted, transmitted and dropped with no seeding, no error and no + stored trace + +**Console consumers:** the companion objectui change trims the matching dead +delegates from `useClientNotifications` (`@object-ui/react`), so a host calling +`registerDevice` / `unregisterDevice` / `getPreferences` / `updatePreferences` +through that hook loses them at the same time (objectui#2862). + +Kept: `client.events` (explicitly a local in-memory buffer), the +dispatcher-served `notifications.list/markRead/markAllRead`, `approvals.*`, and +`meta.getLegalNextStates`. Re-adding any removed surface now requires the server +route to exist *and* a route-ledger row proving it. + +Two REST↔client mismatches are reconciled at the same time: marketplace publish +moves from `POST /api/v1/packages` to **`POST /api/v1/packages/publish`** (the +bare path collided with install semantics and REST wins first-match, so every +`packages.install` call 400'd), and `meta.getView` stops speaking a `?type=` +query dialect only the dispatcher understood. + +#### The `ai` namespace now expresses the AI surface that exists (#3718) + +| SDK | Route | +|---|---| +| `ai.chat(request)` | `POST /api/v1/ai/chat` — forces `stream: false` | +| `ai.chatStream(request)` | `POST /api/v1/ai/chat` — `AsyncIterable` of UI Message Stream frames | +| `ai.complete(request)` | `POST /api/v1/ai/complete` | +| `ai.models()` | `GET /api/v1/ai/models` — the plan-filtered picker list | +| `ai.conversations.*` | the six `/api/v1/ai/conversations` routes | +| `ai.agents.*`, `ai.pendingActions.*` | the agent + pending-action routes | + +`ai.chatStream` returns a *promise for* an async iterable rather than being an +async generator, so the request is issued — and an HTTP error thrown — when you +call it, not when you first iterate. + +`service-ai` is a Cloud/EE package: this repo proxies `/api/v1/ai/**` and, without +it, answers 501 — the route is mounted, the implementation is not — so check +`discovery.services` before calling. For a React chat UI, `useChat()` (`@ai-sdk/react`) remains the better +client — these methods are for callers that are not components. + +The spec's dead AI declarations retire with the namespace: +`Ai{Nlq,Suggest,Insights}{Request,Response}[Schema]`, `DEFAULT_AI_ROUTES` +(`getDefaultRouteRegistrations()` returns 8 groups), and the `AiProtocol` +interface. The real server contract is `IAIService` + `IAIConversationService` +in `@objectstack/spec/contracts`. + +#### The GraphQL surface is removed (#2462) + +GraphQL was schema-only from day one: 20+ config schemas, a `handleGraphQL` that +answered 501 unconditionally because `kernel.graphql` was never assigned, and +three separate mounts advertising the dead endpoint. `api/graphql.zod.ts` and +`contracts/graphql-service.ts` are deleted; `graphql` is removed from +`CoreServiceName`, `ApiProtocolType`, the query-adapter dialects and the +discovery/router route fields. **`/graphql` now 404s** (it used to 501). + +Not removed: the `'graphql'` protocol option on **external datasource** lookups — +third-party systems may speak GraphQL, and that is not our API surface. + +#### The `ObjectStackProtocol` composition alias is dissolved (ADR-0076 D9, #3606) + +The transitional union of the twelve per-domain contracts — plus its parallel +`ObjectStackProtocolSchema` Zod object and `ObjectStackProtocolZod` inferred type, +171 schema lines — is removed. Capability availability comes from the runtime +discovery `services` registry; a static union was its degraded snapshot. + +**Migration:** depend on the narrowest per-domain slice you actually use +(`DataProtocol`, `MetadataProtocol`, …), composing them the way REST does +(`DataProtocol & MetadataProtocol`). `ObjectStackProtocolImplementation` now +declares exactly the four domains it provides — Data, Metadata, Analytics, +Package — which the type system enforces. Breaking for importers of the alias or +the Zod schema; **no runtime behaviour change**. + +`objectql`'s protocol re-exports are dropped in the same step: the protocol +assembly is single-sourced through `metadata-protocol`. + +#### A datasource that cannot connect fails the boot (#3741, #3758, #3826) + +- **A declared datasource that objects bind to must connect.** Previously only + an `external` datasource with `validation.onMismatch: 'fail'` fail-fasted; + everything else degraded to one `warn` line. An app declaring + `datasource: 'analytics'` with 20 objects bound to it, booted against a wrong + URL, started clean, exited zero, and then failed every read and write of those + 20 objects with `Datasource 'x' is not registered`. +- **`objectql.init()` refuses to boot** when a data driver fails to connect. +- **The standalone `default` datasource is now a declaration**, connected through + the one `DatasourceConnectionService` path instead of being pre-built and + smuggled in as a `driver.*` kernel service with its own copy of the + failure policy. +- **`/ready` reports 503** when a data driver stops answering, and a + down datasource is visible in Setup with the operator-facing reason. +- Connection attempts are bounded at 10s with an accurate error message. + +If you relied on a boot that survived an unreachable non-default datasource, +that boot was already broken — it just failed later and with a worse message. + +#### `unique` materializes per tenant (#3696) + +`unique: true` became a single-column **global** index that ignored `tenancy` +entirely, while the autonumber sequence table is keyed by +`(object, tenant_id, field, scope)` and hands every tenant its own counter +starting at 1. Tenant B's `PROD-00001` was rejected by an index it could not +see — and the rejection doubled as a cross-tenant existence oracle. The index is +now tenant-scoped, matching the sequence. + +#### Aggregation result shapes (#3839, #3849) + +- **One key for the empty group bucket.** Both aggregation paths now emit a real + `null` for the empty bucket instead of two different placeholder spellings. +- **A group key is the column's value, in the shape `find()` presents it.** + Grouping and reading no longer disagree about the representation of the same + column — including SQLite `Field.datetime`, which used to collapse every row + into one `(null)` bucket, and raw epoch storage that `aggregate()` / + `distinct()` leaked. + +#### i18n routes answer in the shapes they declare (#3676, #3778, #3847) + +- **`/i18n/labels/:object/:locale`** emits the declared entry object + (`{ label, help?, options? }`) instead of a bare `Record`. + A client typed against `GetFieldLabelsResponse` read `labels[field].label` and + got `undefined`; the SDK's type was right and the servers were wrong. + `help` and `options` stop being discarded. +- **`/i18n/locales`** answers in one shape, found by a new success-envelope + conformance suite that parses every `/i18n` success body against the schema + the route declares — rather than against a hand-written literal, which is what + let three of these ship green. +- **The `translation` metadata type speaks `objects.`**, the shape every + resolver, `os i18n extract`, the Console hooks and all nine shipped bundles + already used. It had been registered against an object-first `o.` + schema, so a translation authored in the product saved successfully and then + rendered nothing. Real-world footprint of the retired shape was zero — all + three `*.translation.ts` files in the tree were already `objects.`-shaped — + so this is a registration fix, not a migration. +- **`GetTranslationsRequest` is locale-only.** The `namespace` / `keys` filters + were declared, put on the query string by the SDK, and read by neither serving + surface; passing `keys` shrank nothing and reported nothing. + +#### The dispatcher's `error.code` is the semantic code, not the HTTP status (#3842) + +Everything the runtime dispatcher serves — `/meta/*`, `/actions/*`, +`/packages/*`, `/automation/*`, `/analytics/*`, `/ready`, the route-not-found +404 — used to answer with the HTTP status in `error.code`, the field +`ApiErrorSchema` declares as a semantic string. The real code had to live +somewhere else and did, in three different somewhere-elses: `error.details.code`, +`error.details.type`, and a sibling `error.type`. + +```jsonc +// before // after +{ "error": { { "error": { + "message": "…", "code": "PERMISSION_DENIED", + "code": 403, "message": "…", + "details": { "code": "PERMISSION_DENIED" } "httpStatus": 403 +} } } } +``` + +- **`error.code`** is the semantic string. **`error.httpStatus`** is the number. + **`error.details`** is context only. Replace a + `body.error.details?.code ?? body.error.type` read with `body.error.code`, and + a `body.error.code` read (for the status) with `body.error.httpStatus`. +- **SDK callers need no change.** `ObjectStackClient` already normalised this — + `err.code` semantic, `err.httpStatus` numeric. The old-shape fallback read was + retired later in this same window (#4007): SDK and server ship on one release + train, and the ADR-0112 rename changed the code values a dug-out code would + need to match. +- **No code was renamed.** `PERMISSION_DENIED`, `ROUTE_NOT_FOUND`, + `PASSWORD_EXPIRED`, `PROJECT_MEMBERSHIP_REQUIRED`, `VALIDATION_FAILED` and + `unauthenticated` all reach the wire spelled exactly as before; only their + field changed. Reconciling the platform's two code vocabularies is #3841. + A branch with no code of its own now derives a `StandardErrorCode` from the + status (`403` → `PERMISSION_DENIED`, post-rename spelling), spelled in one map in the spec. +- **Spec:** `ApiErrorSchema` gains optional `httpStatus`; `StandardErrorCode` + gains `method_not_allowed` and `precondition_required` (both additive). + `DispatcherErrorCode` changes members from `'404' | '405' | '501' | '503'` to + the four semantic spellings the removed `error.type` declared, and + `DispatcherErrorResponseSchema.error.code` becomes a string — it had declared + the opposite of `ApiErrorSchema` for the same field, which is what let the + deviation stand. + +#### Anonymous access is denied unconditionally — `api.requireAuth` is gone (#3963) + +`api.requireAuth` was a deployment-wide opt-out: one boolean that let a stack +serve its **entire** data plane to unauthenticated callers. Auth is a kernel +concern, not a deployment posture, so the key is retired and anonymous access to +object data is now denied on every HTTP surface. A stack that mounts no auth at +all fails at boot when it would serve a data API, instead of receiving an +implicit fail-open. + +Publish public surfaces by **declaration** instead — each derives its own narrow +authorization rather than opening the whole data plane: a public form view +(`sharing.allowAnonymous`), a share-link token (read as SYSTEM), or +`book.audience: 'public'` (ADR-0046 §6.7). `os migrate meta` drops the key and +emits a notice telling you where public access has to be re-declared. + +#### `wait` never had a timeout — `timeoutMs` / `onTimeout` are retired (#4158) + +Both keys described a timeout and neither delivered one. +`waitEventConfig.onTimeout` had **zero** readers — no path ever inspected it, so +neither `fail` nor `continue` ever happened, while its `.default('fail')` +stamped a decision nothing made onto every wait node. +`waitEventConfig.timeoutMs` said "maximum wait time before timeout", but its +only reader used it as the timer *duration* when `timerDuration` was absent: it +did something, just not what it claimed. A wait resumes when its timer elapses +or its signal arrives — never on a deadline. + +`os migrate meta` converts `timeoutMs` → `timerDuration` (stringified — a bare +numeric string reads as milliseconds, so the wait is unchanged) and drops it +outright when `timerDuration` was already set. `onTimeout` is deleted; there is +no replacement. Real timeout semantics are left to be built to a requirement +rather than retrofitted onto two keys that happened to be declared. + +#### The query request surface sheds five inert keys (#4196, #4286) + +`QueryAST` is a **request** shape — never stored in stack metadata — so these +are caller-side edits, not a source rewrite: `os migrate meta` has nothing to +convert. Each key now fails to parse with its prescription, and authoring one is +a `tsc` error at the call site. + +| Removed | Why | Use instead | +|---|---|---| +| `fields[]` object form `{ field, fields, alias }` | inert end to end — every reader treats the list as `string[]`, so it was dropped by the SQL and memory drivers, projected as a column named `"[object Object]"` by MongoDB, and refused by the REST ingress | `expand`, keeping the reference column in the projection (`fields: ['title', 'owner']` plus `expand`) | +| `query.joins` | no engine or driver ever read it; the name squatted on the reserved REST parameter set (the `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with it) | `expand` — the engine resolves it via batch `$in` queries | +| `query.windowFunctions` | `find()` never applied one, so every OVER clause was silently dropped; `WindowFunctionNode` declared `field`/`over`/`frame` members the live door never read | `aggregations` + `groupBy`; embedders on a SQL datasource call `SqlDriver.findWithWindowFunctions()` | +| `query.cursor` | no driver implemented keyset pagination — the cursor was accepted and ignored, so every page came back identical and a caller looping "until `hasMore` is false" never terminates | a `where` predicate on your sort key (`{ created_at: { $gt: last.created_at } }`) with the matching `orderBy` | +| `query.distinct` | **mis-wired**, not merely dead: no driver rendered SELECT DISTINCT, and its only observable effect was suppressing the REST list count — callers got duplicate rows *and* a degraded `total`/`hasMore` | `groupBy`, the `count_distinct` aggregation, or the drivers' `distinct(object, field)` door | + +`QueryBuilder.cursor()` and `QueryBuilder.distinct()` are deleted with their +keys. The count suppression goes too, so `total` is truthful again for queries +that used to send `distinct`. + +`BatchOptions.validateOnly` (#4052) is retired on the same grounds: it promised +a dry-run ("validate records without persisting") that no batch surface ever +honoured — `updateManyData`, `deleteManyData` and `batchData` persisted +regardless, so a caller sending it to *preview* a mutation got it executed. It +is HTTP-only; stop sending it. + +#### `findOne` must say which record it wants (#4419) + +`findOne` reads a single row. That makes its predicate the only thing standing +between the caller and *an arbitrary record* — and when the predicate is missing +the result is not `null`, it is the object's **first row**: a real, +plausible-looking record with nothing to do with the request, which the +`if (!row)` check every call site already has cannot catch, and which then +propagates into whatever is computed next. Downstream of one such call, line +items defaulted their price from the first product in the catalog, and +"is this deal already closed?" was answered against an unrelated record while +the write that followed correctly targeted the intended id. + +So a query that selects nothing in particular is now **refused** rather than +answered. Say which record you want in one of three ways: + +| Instead of | Write | Meaning | +|---|---|---| +| `findOne(o)` / `findOne(o, {})` / `findOne(o, { where: {} })` | `findOne(o, { where: … })` | the record matching this predicate | +| | `findOne(o, { search: 'Acme' })` | the record this search finds | +| | `findOne(o, { orderBy: [{ field: 'created_at', order: 'desc' }] })` | the FIRST record in this order — the newest | +| | `find(o, { limit: 1 })` | any row will genuinely do — and the call site says so | + +The error names all four. `find` and `count` are unchanged: returning or counting +every row is an honest answer, and only `findOne`'s implicit "just one of them" +turns a missing predicate into a confidently wrong record. + +Two silent drops that produced the same wrong record are fixed with it: + +- **`findOne({ search })` now applies the search.** The ADR-0061 `search` → + cross-field `$contains` expansion ran in `find` only, while both methods are + checked against the same legal-key set — so `search` passed the gate, reached a + driver that does not read it, and the read came back unpredicated. The + expansion is now one function both call, and a drift pin requires every option + `findOne` declares to have an observable effect. +- **`MongoDBDriver.findOne` now applies `orderBy`, `fields` and `offset`.** It + translated `where` and dropped the rest, so "the newest record" returned + whichever document the scan reached first. No ordering is imposed when the + caller supplies none (#4363) — that part is unchanged, on both drivers. + +Together with the `filter` → `where` fold on every entry point and the +unknown-key rejection (both already in this release), a read parameter the engine +does not execute now fails at the call site instead of quietly changing the +answer. + +#### Dead spec clusters removed + +**App shell (2026-06 liveness audit, #4001 app step).** `App.version`, +`App.aria`, `App.objects`, `App.apis`, `App.sharing`, `App.embed` and +`App.mobileNavigation` are tombstoned — none was ever read by framework or +objectui. `sharing`/`embed` were the dangerous pair: a declared public-access +surface no route enforced (the live path is `FormView.sharing`). +`mobileNavigation` was a mode picker that changed nothing. Each key rejects +with its prescription; `os migrate meta` deletes them from your source. + +Each of these parsed and did nothing. None has a runtime consumer; delete the +import or the authored key. + +| Removed | Note | +|---|---| +| `PortalSchema` | portal metadata was never enforced | +| `AuditConfig` cluster (`@objectstack/spec/system`) | dead since #1878 | +| Capabilities-descriptor cluster (`ObjectQL`/`ObjectUI`/`Kernel`/`ObjectStack`/`ObjectOS CapabilitiesSchema`) | static snapshots superseded by runtime discovery | +| `FeatureFlagSchema` (`kernel` feature.zod) | orphaned module | +| `DevPluginConfigSchema` cluster (`kernel` dev-plugin.zod — `DevServiceOverride` / `DevFixtureConfig` / `DevToolsConfig` / `DevPluginPreset`) | a declared dev-mode protocol (presets, fixtures, dev-tools dashboard, per-service mock/stub strategies, simulated latency) nothing implemented and no load path parsed — `@objectstack/plugin-dev` reads its own `DevPluginOptions`, and the `strategy: 'stub'` vocabulary described the dev-stub design ADR-0115 retired (#4149) | +| `SkillSchema.permissions` | never gated anything (#3686) | +| `tool.requiresConfirmation` | a safety flag nothing enforced (#3715) | +| `object.enable.trash` / `enable.mru` | ADR-0049 enforce-or-remove close-out (#2377) | +| `ReportColumnSchema` / `ReportGroupingSchema` + report chart `groupBy` | unread (#3463) | +| Report `aria` / `performance` props | report-liveness close-out | +| `DataQualityRulesSchema` / `ComputedFieldCacheSchema` | orphaned value schemas (#3726, #3733) | +| `DynamicLoadingConfig` / `PluginDiscoveryConfig` / `PluginDiscoverySource` | promised plugin sandboxing / integrity verification / source allow-listing / load approval; none of it was ever wired (#3950) | +| `RowLevelSecurityPolicy.priority` | conflict-resolution semantics that cannot exist under OR-combination (#3990) | +| `tool.category` / `.permissions` / `.active` / `.builtIn` (+ `ToolCategorySchema`) | authorable and inert — `permissions` gated nothing, `active: false` withdrew nothing; strict-rejected with prescriptions (#3896 close-out) | +| `action.shortcut` / `.bulkEnabled` | no keydown path dispatches shortcuts; the multi-select toolbar reads the view's `bulkActions` (#3896 close-out) | +| `flow.active` / `.template` / node `outputSchema` / `errorHandling.fallbackNodeId` | `active: false` never stopped a flow — `status` is the enforced lifecycle; faults route via per-node fault edges (#3896 close-out) | +| view: list `responsive`/`performance`, form `defaultSort`/`aria` | no renderer read any of them; list `aria`/`data` **and form `data`** stay live — `defineForm` writes `data: { provider: 'schema', schemaId }` onto every metadata form (#3896 close-out) | +| `dashboard.aria` / `.performance` / widget `performance` (+ `PerformanceConfigSchema`) | no renderer applied them; virtual scrolling is the live top-level `virtualScroll` (#3896 close-out) | +| `agent.knowledge` (+ `AIKnowledgeSchema`) | declaring sources/indexes never scoped retrieval — `search_knowledge` takes `sourceIds` from the LLM's tool-call arguments (#3896 close-out) | +| `PluginLifecycleSchema` (`onInstall`/`onEnable`/`onDisable`/`onUninstall`/`onUpgrade`) + `UpgradeContextSchema` + the `ObjectStackPlugin` interface family (`@objectstack/spec/system`) | a plugin lifecycle the kernel never implemented — the real contract is `init`/`start`/`destroy`; code written against the hooks silently never ran (#4212) | +| The typed-event cluster (`kernel` plugin-lifecycle-events.zod — ten payload schemas (`PluginRegisteredEvent`, `HookTriggeredEvent`, `KernelReadyEvent`, …), the 21-name `PluginLifecycleEventType` enum, `ITypedEventEmitter`) | a typed-event system that was never built: zero consumers, and the enum was wrong in both directions — 17 names nothing fires, 10 real events missing. `IPluginLifecycleEvents` is now the registry of the 14 events with a real emitter, and the new `LifecycleEventName` union soft-types `PluginContext.hook`/`trigger` (#4212 follow-up, #4241) | +| `skill.triggerPhrases` | phrases were never matched; activation is `triggerConditions` + the agent's `skills[]` allowlist (#3896 close-out) | +| `DEFAULT_DISPATCHER_ROUTES` | dead route table | +| Aspirational config on Theme / Translation / Webhook | still-dead after #3494 | +| `ChartInteraction.zoom` / `.clickAction` | never implemented (#3752) | +| The `workflow` service slot — `CoreServiceName 'workflow'`, `IWorkflowService`, `WorkflowProtocol`, the `Get/WorkflowState/Config/Transition` schema cluster, discovery `routes.workflow` / `services.workflow` / `features.workflow`, the `RestApiRouteCategory 'workflow'` member and the stray `graphql` provider entry | declared end to end and implemented nowhere: nothing ever registered or resolved the slot (ADR-0115 Evidence 5), no method of `WorkflowProtocol` was ever implemented, no host ever mounted `/api/v1/workflow`. State machines are `state_machine` validation rules; approvals are flow nodes (ADR-0019); record-triggered automation is hooks + `record_change` flows (#4451) | +| `datasource.readReplicas` | replica connections nothing ever opened — no driver reads the key and no query path splits reads from writes, so every statement went to the primary. #4410 had just taught the schema to validate each entry against the declared driver's contract, which made a dead slot look rigorously alive (#4468) | +| The per-provider connector "template" cluster (`@objectstack/spec/integration` — `DatabaseConnectorSchema`, `FileStorageConnectorSchema`, `GitHubConnectorSchema`, `MessageQueueConnectorSchema`, `SaasConnectorSchema`, `VercelConnectorSchema`, their ~100 sub-schema/type/example exports, and the six generated reference pages) | the losing side of a decided architecture fight, left standing: ADR-0023 rejected hand-modelling each external system's shape inside the spec, and the live ADR-0097 protocol gets provider shapes from the provider itself (connector-openapi / connector-mcp materialize at boot). Zero consumers — `engine.registerConnector()` validates against `ConnectorSchema` from `connector.zod.ts` alone, and nothing referenced the six files, not even their own module's live half. `DatabaseConnectorSchema` also declared read-replica routing a *second* time (`readReplicaConfig`, see the row above), down to a `weight` field for a load balancer that does not exist (#4480) | +| The `trigger-registry.zod.ts` Connector cluster (`@objectstack/spec/automation` — `ConnectorSchema`, `ConnectorInstanceSchema`, `ConnectorOperationSchema`, `ConnectorTriggerSchema`, the `Authentication*`/`OAuth2Config`/`Operation*` vocabulary, the `Connector.apiKey()`/`.oauth2()` factory helpers, and the generated reference page) | the *third* declaration of the same business need, and the file never contained what its name promises — no trigger registry, 630 lines of connector vocabulary with zero consumers. The automation engine registers connectors against `integration/connector.zod.ts` (ADR-0097) and the stack `connectors:` collection parses `DeclarativeConnectorEntrySchema`; nothing registered, validated or executed against this copy. Its header even carried a "When to use" comparison steering lightweight cases here — a signpost to a dead end, removed with it (#4499) | +| The `kernel` metadata-loader envelope family — `MetadataFormat`, `MetadataStats`, `MetadataLoadOptions`, `MetadataSaveOptions`, `MetadataExportOptions`, `MetadataImportOptions`, `MetadataLoadResult`, `MetadataSaveResult`, `MetadataWatchEvent`, `MetadataCollectionInfo`, `MetadataLoaderContract` (`@objectstack/spec/kernel`) | eleven names that each existed **twice**, with a different shape, on `./kernel` and `./system` — so which type you got depended on your import path. Every consumer imported the `./system` copy; the `./kernel` copies had zero consumers. Import them from `@objectstack/spec/system` (#4411, ADR-0049). `MetadataManagerConfig` / `MetadataFallbackStrategy` are unaffected and still ship from both entries | + +The Console side follows: `@object-ui/types` drops its +`ObjectStack`/`ObjectOS`/`ObjectQL`/`ObjectUI` Capabilities re-exports, which +pointed at the same retired cluster (objectui#2860). Import what you still need +from `@objectstack/spec` directly. + +#### Field widgets receive their metadata on one key, `field` (objectui#3233) + +`FieldWidgetComponentProps` no longer declares `schema`. The prop was a second +carrier for what `field` already means: `SchemaRenderer` passed the authored +node as `schema`, the form renderer's `renderFieldComponent` passed +`schema={props.field || props.schema || props}` *alongside* `field`, and about +thirty widgets settled the disagreement themselves with `field || schema` — one +concept, two spellings, a de-facto second contract. + +**Reading the metadata** — drop the fallback: + +```diff +-const config = field || (props as any).schema; ++const config = field; +``` + +**Registering a widget** that can be rendered from a schema node — anything +`SchemaRenderer` dispatches, not just forms — wrap it once so it still receives +`field`: + +```diff ++import { withFieldCarrier } from '@object-ui/fields'; ++ +-ComponentRegistry.register('color', ColorField, { namespace: 'field' }); ++ComponentRegistry.register('color', withFieldCarrier(ColorField), { namespace: 'field' }); +``` + +`withFieldCarrier` forwards the node **by reference** — nothing is copied, +narrowed or renamed — and consumes `schema` so it cannot reach the DOM through a +widget's `...props` spread. The SDUI node → `field` translation now happens +exactly once, in that adapter, and every built-in field widget is registered +through it. + +**Who is affected:** anyone who wrote a field widget. In TypeScript, reading +`props.schema` is now a compile error rather than a silent `any`; a third-party +widget that keeps reading it and is **not** re-registered through the adapter +reads `undefined` in 17 and renders its empty or default state without +complaining. That is the deliberate cost of a major boundary — one contract +beats N dialects, and picking the wrong spelling should fail at compile time +rather than work under one host and not another. + +**Host metadata is untouched.** No authored SDUI JSON changes — this is a change +to how widgets are *written*, not to what apps declare. `schema` also remains the +universal SDUI prop every registered component receives from `SchemaRenderer` +(`element:*`, `page:*`, grids, reports); only the *field-widget* contract retired +it. + +#### Flow node geometry is the spec's `FlowNode.position` (objectui#3172) + +The flow designer writes node coordinates as `position: { x, y }` — the key +`@objectstack/spec` has modelled all along — instead of its own `ui: { x, y }`. +Dragging, adding-at-a-point and insert-on-edge each wrote the local spelling; +all three now write `position`, and the canvas migrates on write: a stored +flow's legacy `ui` is lifted onto `position` and the key removed in the first +patch the canvas emits, geometry-related or not. + +**This is a behaviour fix, not a rename.** `FlowNodeSchema` has been `.strict()` +since #4001, so `ui` is an `unrecognized_keys` error: client validation flagged +the draft on every keystroke and the server rejected the save with a 422. In +other words, dragging a node made the flow unsavable — the convergence is what +makes the designer's most basic gesture round-trip again. + +**Who is affected:** anything reading `node.ui` off a flow draft. After the +author's first edit the key is gone and the coordinates live under +`node.position`: + +```diff +-const { x, y } = node.ui; ++const { x, y } = node.position; +``` + +**Reading a stored flow stays backwards-compatible.** `manualPosition()` prefers +`position` and falls back to a legacy `ui`, so a flow saved before this change +still opens with its nodes exactly where the author left them. The fallback is a +migration path, not a second contract: nothing writes `ui`, and the canvas +strips it at its input boundary, so no patch can re-emit it. Nothing in this +repo or the engine ever read the key — it was designer-local, and the schema +rejected it — so the migration reaches only code written against the designer's +own drafts. + +#### Smaller breaking changes + +- **MongoDB driver declares itself single-tenant** and refuses to boot in a + multi-tenant configuration rather than silently mixing tenants (#3724). +- **Multi-organization operation is an entitlement again.** The `group` posture + no longer self-activates — it requires the enterprise runtime + (`@objectstack/organizations`). The first ADR-0105 wave made it + self-activating, which turned `group` into a free multi-org path around the + `isolated` gate and made the *weaker* isolation the free one. +- **`bootStack({ multiTenant: true })` now requests the `isolated` posture** + explicitly. +- **Kernel-built assignment notifications are dropped** from `plugin-audit`; + the policy moves to user-space automation (#3403). +- **`sys_view_definition`'s all-six `apiMethods` whitelist is dropped** (#3026). +- **`os migrate plan` shows index drift** — index DDL is no longer applied + silently at boot (#3728). +- **A flow's `errorHandling.strategy: 'retry'` must state `maxRetries` (>= 1)** + (#4247). `maxRetries` had two defaults — `.default(0)` in `FlowSchema` and + `?? 3` in the engine's `retryExecution` — so an unstated count retried 0 times + for a flow that had been through the schema and 3 times for a definition + handed to the engine directly. The engine's copy is gone (it reads the parsed + block with no fallback), and the case that was ambiguous is rejected rather + than guessed: retrying zero times is `strategy: 'fail'` under another name, + and a retry re-runs the *whole* flow, so the count is the author's to state. + Fix: write `{ strategy: 'retry', maxRetries: 3 }`, or `strategy: 'fail'` if no + retry was intended. `maxRetries: 0` stays legal under `'fail'` / `'continue'`, + which never read it. +- **`ObjectQLEngine.use()` and `ObjectQLHostContext` are removed** (#4212 + follow-up, #4242). The engine's own plugin loader — register a manifest + part, then dispatch the runtime part's `onEnable` over an + `ObjectQLHostContext` — had zero callers repo-wide, and its `onEnable` was + the engine-level twin of the #4212 disease: a lifecycle entry point that + reads as a contract and never runs. Fix: `engine.use(manifest)` → + `engine.registerApp(manifest)`; `engine.use(_, { onEnable })` → a kernel + plugin (`kernel.use({ name, init(ctx) { … } })`, engine via + `ctx.getService('objectql')`, drivers via `engine.registerDriver()`). + `new ObjectQL({ logger })` and `ObjectQLPlugin`'s `hostContext` option keep + working, and the *app-bundle* `onEnable` module export (dispatched by + AppPlugin at boot) is a different, real contract — unchanged. + +### New capabilities in 17.0.0 + +#### Files become platform records (ADR-0104) + +The headline of the line. `@objectstack/spec/data` now owns the **runtime value +shape** of every field type (`field-value.zod.ts`): semantic type classes, +`isMultiValueField`, and `valueSchemaFor(field, 'stored' | 'expanded')`. The +four consumers that each hand-copied this knowledge — the objectql record +validator, REST import coercion, driver-sql column classification, and the QA +conformance matrix — derive from the spec instead, and the field-zoo round-trip +matrix is asserted against the contract so they cannot drift. + +For media fields specifically: + +- **The stored form narrows to an opaque `sys_file` id.** The inline + `{url, name, size, …}` blob becomes the `'expanded'` read form, which still + admits an unresolved id exactly as an unexpanded lookup id stays valid. +- **`accept` and `maxSize` are declarable on `FieldSchema` and enforced on the + server.** Both were already read by the upload widgets while the spec did not + declare them, so authoring them meant a silently stripped key and a constraint + that never existed. Because the platform now owns the file, `sys_file` carries + the authoritative MIME type and byte size, so a record write is re-checked + where it binds — a browser-side check is a convenience, not a control. + Violations raise `FileConstraintError`. An entry is judged only against + metadata the file actually reports: "we don't know" never becomes + "not permitted". +- **Exclusive field-reference ownership**, a **governed download** path for + field-owned files, and an object's ability to **delegate file-read + authorization to its service** replace the unguessable-URL model. Downloads + carry the real filename and content type instead of the URL token, and + `_local/file/:key` — a URL nothing mounted — is gone. +- **Two legacy forms stop conforming, deliberately:** the inline blob (no longer + stored, now derived) and the external URL (never a managed file — it retires + toward an explicit `url` field, so "managed file" and "external link" stop + being the same declaration). + +**Value-shape checking follows your own migration, not the version number.** A +not-yet-backfilled row still writes, and the author gets a warning naming the +field. Media fields start *rejecting* malformed values only once **this +deployment** has run the migration and passed its self-check: + +```bash +os migrate files-to-references # dry run: reports, writes nothing +os migrate files-to-references --apply # converts, verifies, records the flag +``` + +The run backfills legacy file-field values (inline metadata blobs, own-resolver +URLs, `data:` URIs) into owned `sys_file` references and reconciles the ownership +ledger against what records actually hold. The **deployment-level flag it +records** — never the platform version — is what authorises both strict media +value shapes and irreversible file collection. Upgrading changes neither; +running the migration does. + +**The non-media classes have their own gate** (#3438) — references (`lookup`, +`master_detail`, `user`, `tree`) and structured JSON (`location`, `address`, +`composite`, `repeater`, `record`, `vector`): + +```bash +os migrate value-shapes # scan: reports, writes nothing +os migrate value-shapes --apply # scan, then record the flag if clean +``` + +A separate flag because it attests a separate fact: the file migration says file +values were converted and their ownership reconciled, which tells you nothing +about whether a `lookup` id or a `location` payload is well formed. This one +converts nothing — a malformed `location` is application data only its author +can correct — so it reports the object, field, type, count, sample record ids +and parse issue, and you re-run until it is clean. `OS_ALLOW_LAX_VALUE_SHAPES=1` +re-opens leniency while diagnosing. + +**You are told about both, rather than having to find this page.** `os migrate +meta` — the command a 16→17 upgrade already runs — ends by naming the two data +migrations and the gate each records, including on a run that rewrote nothing, +since canonical metadata says nothing about stored values. A deployment that +boots holding covered fields with no verified gate row logs one line naming the +command that ends warn mode. Neither reads as "done": until a gate is recorded, +the classes it covers keep warning. + +That boot line is scoped to the deployments it can actually help. It counts only +fields a write would check — not the `lookup`s the registry injects into every +object — and says nothing once an environment switch has already settled the +posture, since `OS_DATA_VALUE_SHAPE_STRICT_ENABLED` enforces both classes +outright and either `OS_ALLOW_LAX_*` opt-out is a deliberate choice the scan +would not change. + +**A database created by 17 attests both flags at creation** (#3438), so a new +deployment enforces from its first boot instead of waiting for someone to run a +migration that, for an empty store, does nothing. The platform attests only a +store it watched itself create — every table made by that boot, none found +already there; an upgraded or restored database attests nothing and produces its +evidence by running the command. + +**Released-file collection is live behind that same flag** (#3459). On a +verified deployment, a field file whose one owning record lets go — the field +cleared, or the record deleted — is tombstoned into the declared 30-day grace +window; re-referencing the id inside the window revives it, and past it the +platform sweep re-verifies at delete time that nothing holds the file (join +rows, ownership columns, and a fresh read of the flag itself) before +reclaiming the row and its bytes. A deployment that never migrates keeps every +released file forever: upgrading is not consent — passing your own +migration's self-check is. + +Two knobs sit either side of that flag on the value-shape half. +`OS_ALLOW_LAX_MEDIA_VALUES=1` returns a verified deployment to warnings, for an +operator who hits an unforeseen rejection and needs writes flowing while they +diagnose. `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1` goes the other way and opts +*every* value class into strict immediately — including the reference +(`lookup`/`user`/…) and structured-JSON (`location`/`address`/…) types, whose +own per-deployment gate is still being built (#3438). Those stay warn-only by +default until it lands, because unlike media they have no migration standing +behind them yet. + +#### Approvals: dynamic approver routing (#3447) + +- **`expression` approvers.** A CEL expression resolves *who* approves at node + entry, over exactly three roots: `current.*` (the record's live state), + `trigger.*` (the submit-time snapshot) and `vars.*` (flow variables, including + upstream node outputs). Bare `record` and bare field names are rejected before + evaluation — on this platform `record` always means "the record at event + time", which is ambiguous at an approval node — with error messages that + prescribe the correct spelling. Optional + `resolveAs: 'user' | 'department' | 'position' | 'team'` re-expands each + resolved id through the same graph lookups the static types use; with + `behavior: 'per_group'` each intermediate value forms its own sign-off group. +- **`onEmptyApprovers` policy**, node-level, for every approver type: + `admin_rescue` (default — the request opens for privileged takeover), + `fail`, or `auto_approve` (skip the request and continue down the `approve` + edge with `output.autoApproved = true`). +- **Decision outputs.** The author declares allowed keys on the node + (`decisionOutputs`); approvers fill values only; accepted outputs resume the + run as `.` variables, so a later node's expression can read + `vars..picked_departments`. Undeclared keys reject the decision; + `decision` and `requestId` are reserved. A `decisionOutputs` entry may be typed + (`{ key, label?, type: 'text'|'user'|'department'|'position'|'team', multiple? }`) + to make the decision UI render a record picker instead of free text. +- **`field` / `manager` approvers resolve against the record's live state** at + node entry rather than the trigger snapshot the flow froze at submit time, so + an earlier step can write the field that routes a later step's approvers. + Graph approvers already resolved live; this brings the in-record types into + line. +- **Approver value bindings are declared.** `APPROVER_VALUE_BINDINGS` is the + single declaration of how a designer sources each approver row's `value`. + `queue` is deprecated for authoring — it still parses so stored flows keep + loading, but it is published in `xEnumDeprecated` because the runtime has no + queue resolution and the slot routed to nobody. +- Also: cross-organization approver targeting, `department` approvers resolving + against env-wide business units, per-group membership of pending approvers, + inbox rows enriched with snapshot field labels (`payload_labels`), the pending + node's `lockRecord` policy exposed on the request row, decision attachments + returned as real file values, an admin override for requests routed to an + unstaffed approver, a status mirror that names the human who caused the + transition, and a decision recorded against the authenticated caller rather + than a body field. + +#### The SDK reaches the whole REST surface (#3563, #3587) + +Beyond the deletions above, the client gains typed access to everything the +server actually mounts: the `actions` surface, `keys` / `shareLinks` / +`security`, the eleven package-lifecycle methods, metadata drafts/published/FSM, +automation descriptors, the reports family, approvals and record shares, sharing +rules, security-explain, and search. `automation.resume()` / +`automation.getScreen()` finish a paused screen flow from the SDK. + +The ledger is the point: a **route-ledger conformance guard** runs in both +directions — every SDK URL must match a route some surface mounts, and every +mounted route must be reachable or explicitly ledgered — with the REST surface, +the dispatcher and the autonomously-mounted service routes each carrying their +own ledger. `analytics.meta` / `analytics.explain` and two i18n calls that +reached nothing are repaired by the same audit. + +#### Write observability — a silent strip stops reading as a clean save + +`PATCH`/`POST /data` surface **`droppedFields`** when the server silently strips +a write, extended to the bulk paths, the cross-object batch, and the client SDK; +flows surface silently-stripped write fields as step warnings; and a batch +create now goes through the same create ingress as a single create. `os validate` +runs the four authoring lints `os build` runs, so "validate clean, build fails" +is gone. + +#### Analytics correctness + +- `ObjectQLStrategy` **enforces the read scope** (RLS + tenant), and the + read-scope auto-bridge no longer depends on plugin order. +- `timeDimensions[].dateRange` is applied — the predicate every date-bucketed + chart was missing. +- The **effective** date granularity drives bucket labels and drill ranges, and + widget `dateGranularity` / `sortBy` / `sortOrder` / `limit` are honoured in the + dataset query. +- Cross-object grouping is served in-envelope on the ObjectQL path by FK-expand, + and **fails closed** when the path cannot join. +- Dimension-label lookups are scoped to the referenced object's RLS, and dataset + selections sort by display label for select/lookup dimensions. +- Cube auto-inference is gated on object existence, and the dispatcher boundary + stops returning raw SQL. + +#### Automation & flows + +- **Every terminal run reports what it did** (#4354): `selected` / `acted` / + `skipped` totals, a per-node breakdown, and *which gate closed* — on the run + result, in `listRuns` / `getRun`, as one greppable log line + (`selected=30 acted=0 skipped=30 gate=check_stalled->send_nudge:30`), and as + queryable `selected_count` / `acted_count` / `skipped_count` columns on + `sys_automation_run`. `success: true` never meant "it did its job": a sweep + that selected thirty records and wrote none was indistinguishable from one + with nothing to do, which is how three inert production flows ran green for + as long as they had existed. `selected > 0 && acted == 0` over consecutive + runs is now the detector. See + [Run summaries](/docs/automation/flows#run-summaries). +- **`record-after-write`** fires one flow on create **or** update (#3427), with + `previous` bound as `null` on the create leg so start conditions can + discriminate. +- Opt-in single-hop **lookup expansion** for record-change flow templates. +- The **resume gate** is one chokepoint: it follows `map:` too, is gated by the + node the run is parked on, and the route stops accepting engine-internal + variables. +- A `fault` edge must not switch off a guardrail; refuse-to-execute guards lose + their default-routable footgun; a filter that loses a condition must not run; + array-form `triggerType` fails loudly instead of silently never firing; string + templates serialize object tokens readably instead of `[object Object]`. +- `retryPolicy` / `timeout` authored on a job are honoured by the scheduler. +- `{filter-token}` placeholders evaluate server-side, and the + `{current_user_id}` vocabulary is frozen with unresolvable placeholders + failing the build. + +#### Historical data import + +`treatAsHistorical` skips the state machine for historical-data migration **and** +preserves the original audit timeline — including on undo. Row errors are +sanitized so a constraint failure reads as human wording instead of leaking raw +SQL. + +#### Lint & CLI + +New and widened rules: reference-integrity validation for object and action +names, translation-bundle reference integrity and option-key validation, +never-firing record trigger tokens, flow `update_record` writes to readonly +fields, replay-unsafe `mode: 'insert'` seed datasets, seed values outside a +declared state machine, `label: 'error'` written where `type: 'fault'` was meant, +AI surface affinity (skill ↔ agent), the ADR-0109 platform tool-name registry +with an advisory `skill.tools[]` reference lint, and expression/empty-slate/ +reserved-output-key gates for the new approver capabilities. Filter references +and flow template paths that cannot resolve now fail the **build**, not the run. + +On the CLI: `os i18n extract --check` fails instead of writing when bundles have +drifted, `--json` truncation is fixed across every command, the startup banner +reads a DSN-declared datasource and stops printing credentials, and the boot +banner reports seed outcomes (`Seeds: X inserted · Y updated · Z skipped`, +escalating to a yellow `⚠ … N REJECTED` line) so a fixture can no longer lose +most of its rows in silence. + +#### Spec, kernel & platform + +- **`ISecurityService` is published** — the `security` service surface becomes + an enforced contract, with `security.getReadableFields` for export column + projection. +- **API-method derivation is single-sourced**: the server is the only + adjudicator, and the exposure gate's metadata fail-open is observable. +- The **HTTP dispatcher is decomposed** into per-domain modules behind a thin + handler registry (ADR-0076 D11) — auth, ai, automation, packages, share-links, + keys, storage, ui, actions, mcp, meta, data — and request→environment + resolution unifies on the host's `kernel-resolver` seam. +- An action **rejects a `body` on a non-script action** and **rejects unknown + keys on an action param** instead of stripping them; an inline `lookup` param + can declare its reference target. +- `ListColumn` gains `prefix` and the `{ type, field }` `summary` form; page + metadata i18n resolves `page:header` title/subtitle; the filter logical + combinators get one canonical conformance table; `IHttpServer` soft extensions + and unmatched-request semantics are codified. +- Liveness entries gain a `verifiedAt` re-verification clock, and a batch of + ledger claims were re-verified against the real Console consumer — eight of the + last ten preview-only `live` claims were wrong. +- **The ledger's security subset had its first full re-verification** (#3896 + follow-up): all 44 permission/position/object-sharing entries call-graph-closed + by hand and dated. It found the unenforced RLS `enabled` (fixed the same day) + and the void `priority` (removed), refuted two standing suspicions + (`allowExport` *is* enforced server-side; the transfer/restore/purge gates are + pre-mapped fail-closed), and bound two new runtime proofs + (`permission.tabPermissions`, `permission.objects.writeScope`) so the claims + re-prove on every CI run. +- **A new `check:empty-state` gate** scans the authorable surface — spec schemas + *and* platform-object field descriptions, where #3896's *"leave empty to share + every record"* actually lived — for statements declaring a permissive empty + state, and requires each to be classified (`scope` / `closed` / `open` / + `output`) with a rationale. Omission is the commonest authoring error a model + makes; it must not also be the widest grant. +- **File-backed SQLite runs in WAL mode** (#3941). SQLite's built-in rollback + journal makes a writer wait for every reader and leaves an idle connection + invisible to SQL — both wrong for the platform's normal shape, several + processes on one file (a dev server, `os migrate`, a test run). The driver now + switches such a database to WAL on connect, which is also what lets the + `os migrate` occupancy check *see* an idle server instead of inferring one from + file descriptors. Two things to know: `app.db-wal` / `app.db-shm` appear beside + the database while a connection is attached (so back up with + `sqlite3 … ".backup"`, never a bare file copy), and WAL cannot work on a + **network filesystem** — set `OS_DATABASE_SQLITE_JOURNAL_MODE=delete` there, + which converts an already-switched database back. +- Metadata-plane FLS (per-caller masking) is proposed as ADR-0106. + +### New in Console (Studio) — bundled objectui 17.0 + +This section covers the window bundled at **`rc.0`**: `cf2d56e32a11 → +4a4829d0ef39` (`.objectui-sha`) — 128 objectui commits on top of the pin +16.1.0 shipped. The pin has since advanced — the rest of the line is under +*New in Console* in the **Landed since 17.0.0-rc.0** section below. + +#### Files, actions and forms + +- **The file-as-reference value shape is adopted** end-to-end (ADR-0104 D3 + wave 2), including a localized FileField upload widget. +- One precedence for action `target`/`execute`, and server-side `body` stops + being mislabeled; a modal action's `target` resolves as a **page**, not an + object; the spec's `disabled` predicate is honored on every action-rendering + surface; inline `lookup` action params get a real record picker. +- Forms consume spec-aligned `FormView` buttons/defaults, and an invalid submit + scrolls to and focuses the first errored field; the flow-node repeater stops + committing during render. +- Image fields render consistently and support click-to-zoom. + +#### Approvals + +- **Typed output pickers, dynamic decision-output fields, expression approver + editing**, quick-path guard and expression completion — the Console half of + #3447 P2. +- Approval Center triage, density and drawer readability passes; pending-approver + chips labelled with their group; the admin override for a stuck request + surfaced in the inbox; the timeline attachment chip shows its name and opens; + the detail band **honors the node's `lockRecord`** instead of assuming every + approval locks, and distinguishes "in approval (editable)" from locked. +- The inbox renders against one ticking clock. + +#### Data, grids and charts + +- The grid computes **all eleven** spec column summary aggregations, gates row + Edit/Delete and bulk delete on the **effective operation set**, and shows the + real match total under server pagination. +- `` honors the spec `ChartConfig` author shape, its aggregate + result-column naming is a contract, and its axis bindings are validated — + a fieldless `count` aggregate no longer keys its value column `undefined`. + `ChartAxis.stepSize`, `ChartConfig.description` and `.height` are honored. +- Dashboards send widget query options to the server and order funnel stages by + the pipeline; Kanban surfaces off-column records in an Uncategorized lane. +- A toast fires when a save silently dropped read-only fields, and write + warnings stop being lost on the detail page. +- Real per-caller **FLS** is wired into import targets and grid columns, and the + Import Wizard gains an **"Import as historical data"** option; the import + preview validates email format up front. +- **Detail and form edit/delete are gated on the server's effective operation + set** (#3546), the same adjudication the grid rows use — the Console stops + offering a verb the server would refuse. +- Dashboard and chart widget filters resolve `{current_user_id}` (#3574). +- The five per-view-type configs and `ListView` read the spec-canonical + vocabulary (`filter`, `$notContains`, type/label/maxLength keys), and `secret` + stays out of inline edit. + +#### Flows, Studio and Setup + +- A paused **screen flow is completable**: `visibleWhen` is honored in render and + validation, flow actions dispatch from every surface, and the runner stops + tearing down its host. +- Studio gains a first-class **notify** flow node, a "Record created or updated" + start trigger, an `enable.searchable` toggle in the object settings panel, and + step warnings in the Flow Runs panel; the never-firing `record-change` option + is removed from the trigger picker. +- **Setup's datasource list shows the real connect verdict** with the + operator-facing reason; the sharing-rule dialog becomes usable (i18n, a picker + that lists people, permission-aware CTAs); `delegated_admin` is reachable and + both of its pickers are narrowed; scoped-invitation placement invites straight + into a unit and its positions; the flow designer reads approver value sources + off the schema, and approver values become **record lookups** (#3508). +- **Group tenancy posture affordances** (ADR-0105 Phase 1): the org switcher + becomes the write context, and records carry org attribution. +- The API console lists the whole **AI family** and the routes that exist, and + the tool preview stops linking to a 404. + +#### Internationalization & quality + +- **The locale backfill completes: all ten packs reach full key parity.** The + four highest-traffic namespaces are translated into the eight trailing locales, + hand-rolled zh/en branches and `pick({en,zh})` clones are retired, and `en` + becomes the complete source of truth for grid import and set-password. The + system-settings hub is localized. +- ESLint runs on PRs across every package, and the last five unchecked packages + are type-checked — which surfaced two runtime bugs hiding there. +- Console build dependencies take three major bumps — `maplibre-gl` 5→6 (the + `plugin-map` default import is dropped to match), `chalk` 5→6, and `jsdom` + 29→30 (dev) — relevant if you build the Console from source. + + +> The `17.0.0-rc.0` … `rc.6` train that preceded this cut is archived at +> **[17.0.0 pre-release train](/docs/releases/v17/17.0-prerelease)**. + +## Upgrade checklist + +⚠️ One checklist per release, for the release you are landing on **and** every release you cross to get there — and see [how far each list has actually been walked](/docs/releases/v17#upgrade-checklists). + +### 17.0.0 + +- **Node:** move to Node 22+ (and pin CI to 22). +- **Export:** add `allowExport: true` to every environment-authored permission + set whose holders must keep exporting — package-shipped sets are re-seeded for + you, and `member_default` deliberately does not carry the grant. +- **`apiMethods`:** run `node scripts/codemod/apimethods-legacy-to-primitives.mjs`, + replace legacy values with their primitives, and delete the key entirely if all + six remain. Watch for a legacy-only whitelist stripping to deny-all. +- **Metadata renames:** run `os migrate meta --from ` — it + applies `execute`→`target`, `conditionalRequired`→`requiredWhen`, + the retired-key deletions (`os migrate meta` handles all of them), sharing `full`→`edit`, and every + earlier step you skipped, in one pass. +- **Flows:** declare `runAs: 'system'` on any flow that reacts to system writes + and must act beyond one user's grants; otherwise ensure the trigger supplies a + user. +- **Agents:** move anything declared in `agent.tools[]` onto `skills`; drop + `stack.agents` (the runtime has never loaded them). +- **Auth:** plan for the better-auth 1.7 account-identity backfill — check the + boot log for federated accounts whose IdP is no longer registered, and stamp or + remove those rows. +- **Actions:** check any programmatic caller that posts `params` — a bag the + server used to accept silently now 400s if it misses a `required` param, + breaks `options`/`multiple`/`reference`, or carries an undeclared key. The + error names the param and the declared list. `OS_ALLOW_LAX_ACTION_PARAMS=1` + buys time for an integration you cannot reach today. +- **Files:** run `os migrate files-to-references` (dry run first, then + `--apply`) and reconcile — passing its self-check is what turns on strict + media value shapes *and* released-file collection for this deployment, so + read the report before you `--apply`. Do **not** reach for + `OS_DATA_VALUE_SHAPE_STRICT_ENABLED` to get there: it opts every value class + in at once, regardless of which migrations this deployment has actually run. +- **Reference and structured-JSON values:** run `os migrate value-shapes` and + fix what it reports before `--apply`. It converts nothing — the values it + names are application data — and a scan that was truncated or could not read + an object fails the gate even at zero violations. +- **Stored metadata (optional):** run `os migrate meta --stored` to see which + `sys_metadata` rows still carry a pre-17 shape, and `--apply` to rewrite + them. Unlike the two above this opens no gate and nothing depends on it — + those rows already read canonically, forever. It stops them re-converting on + every load, keeps diffs and exports clean going forward, and gives you an + exit code to assert on: nothing left to do exits `0`. +- **Datasources:** verify every declared datasource connects in every + environment — a bound datasource that cannot connect now fails the boot + instead of failing every later query. Delete `readReplicas` (`os migrate meta` + does it). Nothing ever opened those connections, so read throughput is + unchanged by removing them; if you need replica reads, front them behind one + endpoint (pgpool, ProxySQL, an RDS reader endpoint) and point `config` there. + Also re-check what you wrote under `config`: it is parsed against the driver's + contract now, so a key that used to be ignored — and left the datasource on + driver defaults — is rejected by name. +- **Sharing rules:** rewrite `sharedWith.type: 'group'` → `'team'`; drop `guest` + and owner-type rules; expect `accessLevel: 'full'` to convert to `'edit'`. + **A rule must state its criteria** — authoring one without is rejected, and a + stored criteria-less rule stops granting (its materialised grants are revoked + on the next reconcile). State the predicate, or use the object's + `sharingModel` if everyone should read it. +- **RLS policies:** delete `priority` (`os migrate meta` does it; outcomes are + unchanged). Re-check any policy you set `enabled: false` on — disabling now + actually withdraws its grant, which until v17 it silently did not. +- **Tools:** delete `category` / `permissions` / `active` / `builtIn` from tool + metadata (`os migrate meta` does it; none ever had an effect). To gate a tool, + gate the underlying action (`action.requiredPermissions`); to withdraw one, + remove it from the skills/agents that reference it. +- **Close-out sweep:** run `os migrate meta` once more — it also strips + `action.shortcut`/`bulkEnabled`, `flow.active`/`template`/node + `outputSchema`/`errorHandling.fallbackNodeId`, the inert view keys, + dashboard/widget `aria`/`performance`, `agent.knowledge` and + `skill.triggerPhrases`. If you set `flow.active: false` expecting a flow to + stop, set `status: 'obsolete'` — the flag never worked. +- **Anonymous access:** delete `api.requireAuth` (`os migrate meta` does it). If + you relied on `requireAuth: false` to serve something publicly, re-declare it + narrowly — a public form view, a share link, or `book.audience: 'public'` — + and make sure the stack mounts auth at all, or it now fails at boot. +- **Wait nodes:** `os migrate meta` moves `waitEventConfig.timeoutMs` onto + `timerDuration` and drops `onTimeout`. Anything that expected a wait to end on + a deadline never did — build the deadline explicitly if you need one. +- **Query callers:** these are request shapes, so the chain cannot fix them for + you. Drop `joins`, `windowFunctions`, `cursor` and `distinct` from queries and + the `{ field, fields, alias }` object form from `fields[]`; replace + `QueryBuilder.cursor()`/`.distinct()` call sites. Stop sending + `options.validateOnly` to `/batch`, `/updateMany` and `/deleteMany` — it never + previewed anything, it persisted. +- **Required fields:** run `os migrate meta` — it stamps + `storage: { notNull: true }` onto every pre-17 `required: true` field so your + columns keep their exact constraints. New fields: `required: true` alone + write-gates with a nullable column; add `storage.notNull` when you want the + DDL. Audit any client that PATCHes `null` into required fields — that write + is now rejected. +- **Membership:** move business capability off `sys_member.role` and onto + positions (`sys_user_position`). +- **SDK callers:** remove calls to `client.permissions.*`, `client.realtime.*`, + `client.workflow.*`, `client.views.*` CRUD, the notifications + device/preference helpers, `client.ai.{nlq,suggest,insights}` and + `projects.listTemplates()`; repoint marketplace publish to + `POST /api/v1/packages/publish`; drop `os environments create --template`. + Imports of `IWorkflowService`, `WorkflowProtocol` or the + `Get/WorkflowState/Config/Transition` types no longer resolve — the + `workflow` slot retired with them (#4451); use `state_machine` validation + rules, approval flow nodes and `record_change` flows instead. Discovery + responses no longer carry `services.workflow` / `routes.workflow` / + `features.workflow` — a reader keying on them saw only `unavailable`/`false` + before, so delete the read. +- **Console hosts:** the same removals land in objectui — drop the + `useClientNotifications` device/preference delegates (`@object-ui/react`) and + replace the retired `@object-ui/types` Capabilities re-exports with imports + from `@objectstack/spec`. If you build the Console from source, note the + `maplibre-gl` 5→6 / `chalk` 5→6 major bumps. +- **Type importers:** replace `ObjectStackProtocol` / `ObjectStackProtocolSchema` + with the narrowest per-domain slices; drop GraphQL types and any of the removed + dead spec clusters. If you imported `MetadataFormat`, `MetadataStats`, + `MetadataLoadOptions`, `MetadataSaveOptions`, `MetadataLoadResult`, + `MetadataSaveResult`, `MetadataWatchEvent`, `MetadataCollectionInfo` or + `MetadataLoaderContract` from `@objectstack/spec/kernel`, change the path to + `@objectstack/spec/system` — same names, and that copy is the one the runtime + has always emitted. (`MetadataExportOptions` / `MetadataImportOptions` moved + again in #4538: the system-side option bags turned out to have zero consumers + and were deleted, so those two names now come from + `@objectstack/spec/contracts` — the shape `MetadataManager` actually + implements.) It is + the *looser* of the two, so a reader may need new narrowing: notably + `metadataType`/`name`/`timestamp` are optional there. (`MetadataWatchEvent.type` + briefly also declared the raw watcher values `add`/`change`/`unlink`; they had + zero producers — both emit sites normalize — and were removed in #4536, so the + enum now carries exactly `added`/`changed`/`deleted`.) Nothing to migrate at + runtime — the values the runtime emits were always these. +- **Multi-org:** the `group` posture requires the enterprise runtime — deployments + relying on it self-activating must install `@objectstack/organizations` or move + to `isolated`. +- **Approvals readers:** anything that listed requests tenant-wide must now be a + participant or hold the admin override. +- **Raw-HTTP action callers:** branch on the HTTP status — non-2xx is the + failure, and a 200's `data` is the handler's return directly, one wrap less + (#3962). Crashes are 500s now; treat them as server faults. SDK callers + need no change. +- **List-query callers:** malformed filters and unknown + `sort`/`select`/`expand`/`groupBy`/`aggregations`/`searchFields` names now + answer 400 instead of silently over- or under-returning — fix the request + the error names, and note that previously-ignored `orderBy: string[]` and + `{field: direction}` sorts now actually apply. +- **Raw-`fetch` readers** of the settings / datasource-admin / + external-datasource / package / share-link routes: read the payload under + `data` and errors as `error.code` / `error.message` (#3843, #3983). +- **Bulk callers:** stay under `batch.maxBatchSize` (default 200, raisable to + 1000) or chunk; stop sending `object` / `context` in bulk bodies (ignored); + read `deleteMany`'s new structured `BatchUpdateResponse` (#3939, #3897). +- **Analytics clients:** send the bare `AnalyticsQuery` shape (`where`, not + `filters`; no `{cube, query, format}` envelope) (#3878). Embedded / + programmatic hosts must install `@objectstack/service-analytics` — the + context-less fallback is gone (#3891). +- **Bare `HonoServerPlugin` hosts:** mount `@objectstack/rest` or the runtime + dispatcher for data/discovery — the convenience surface and its flag are + deleted (#4073). +- **Direct `new InMemoryDriver()` / `driver: 'memory'` users:** pass + `{ persistence: 'file' }` if you relied on the accidental durability, and + delete stale `.objectstack/data/memory-driver.json` files (#4065, #4083). +- **`artifact-api` sources:** switch to the `local-file` URL form or + `os package install` — a configured `artifact-api` now fails the boot + (#4246). +- **Hand-built kernels:** compose `PlatformObjectsPlugin` if you relied on + storage/settings registering `sys_migration` / `sys_secret` (#4243, #4270); + declare the ADR-0116 ordering fields on plugins that resolve services in + `init()`. +- **SQLite operators:** back up with `sqlite3 app.db ".backup …"` — a bare + file copy can miss committed transactions in the WAL sidecars — and set + `OS_DATABASE_SQLITE_JOURNAL_MODE=delete` on NFS/SMB (#3941). +- **Dev setups:** the fabricating stubs are gone — install the real plugin + for any slot a dev call now finds absent, and note `plugin-dev` refuses + `NODE_ENV=production` without `OS_ALLOW_DEV_PLUGIN=1` (ADR-0115). +- **Sharing admins:** add the new `manage_sharing` capability to the permission + sets held by whoever administers sharing rules (`admin_full_access` carries + it already), and expect edit-level shares to stop conferring delete + (ADR-0111). +- **Flow authors:** rename aliased node-config keys (`subflow`/`map` + `flow` → `flowName`, notify `to`/`subject`/`body`/`url` → + `recipients`/`title`/`message`/`actionUrl`, script `functionName`/`input` → + `function`/`inputs`) or run `os migrate meta` — the conversion windows + close at protocol 18 (#3796, #4278, #4045). +- **Spec importers (dual-source renames):** every break here is a compile-time + `TS2305` naming the symbol, and none of it is authorable metadata, so + `os migrate meta` has nothing to do. Change the **import path** for + `WebhookConfig`/`WebhookEvent` (→ `./integration`), `MetadataEvent`/ + `MetadataBulkRegisterRequest` (→ `./api`), `Notification`/`NotificationConfig` + (→ `./api`), `Session` (→ `./api`), `EventSchema` (→ `./kernel`). Change the + **name** for `PackageDependencySchema` on `./kernel` (→ + `ResolvedPackageDependencySchema`), `RateLimitConfig` on `./integration` (→ + `ConnectorRateLimitConfig`), `FieldMapping` on `./integration` / `./data` (→ + `ConnectorFieldMapping` / `ImportFieldMapping`), `ConflictResolution` on + `./integration` (→ `ConnectorConflictResolution`), `HttpMethod` on `./ui` (→ + `HttpMethodType`), `ActionLocationSchema` on `./studio` (→ + `ActionContributionLocationSchema`), `ShareRecipientType` on `./contracts` (→ + `RecordShareRecipientType`). Deleted outright: the `./automation` `DataSyncConfig` + family, `./system`'s tenant-provisioning family with `IProvisioningService` / + `ITenantRouter` / `ResolvedTenantContext`, and the orphan notification-template + schemas (use `EmailTemplateDefinition` and siblings). +- **Datasource authors (again):** delete `retryPolicy`, `healthCheck`, + `capabilities`, `external.label` and `external.requirePermission` — + `os migrate meta --from 16` removes all of them. None ever had an effect; + `capabilities.readOnly` in particular never made a datasource read-only. + Then verify the **mapping**: an object mapped to a datasource with no live + driver now throws instead of silently reading and writing the default store, + so a mapping you have been carrying decoratively will surface at boot. +- **Driver implementors:** delete `findStream` (the required method nothing + called) and the 31 retired `DriverCapabilities` bits; three bits with real + readers remain. Delete any `IDataEngine.batch` implementation and route + multi-write atomicity through `engine.transaction(cb)`. +- **Bulk/batch callers:** read per-row results under the declared shape — + `errors: ApiError[]` (not `error: string`), `data` (not `record`), plus the + `index` that was never sent. `options.atomic` now **defaults to `false`** and + is a real guarantee when set: an atomic batch either rolls back completely + (zero successes, rows marked `ROLLED_BACK:` / `NOT_ATTEMPTED:`) or is refused + with 501 on a runtime that cannot transact. If you were passing + `atomic: true` and relying on partial results surviving, switch to + `atomic: false`. `deleteMany` / `updateMany` behave identically now (#4620). +- **Hook authors:** a `condition` that cannot be evaluated **aborts the write** + instead of skipping the hook — grep your conditions for keys that are not + declared fields before upgrading. Conditions now read the merged record + (stored ⊕ payload), so `record.x == v` is true on every update of an + already-matching row; write transitions with the new `previous` binding + (`previous.done != true && record.done == true`). +- **Validation authors:** a `script`/`cross_field`/`conditional` rule whose + predicate faults now **rejects** the write instead of being skipped. Expect + previously-silent rules to start firing; that is the defect being fixed, not + a regression. Lower `severity` to `warning`/`info` only where blocking is + genuinely wrong. +- **Flow authors (`script` nodes):** `config.function` is required. Replace + `actionType: 'email' | 'slack'` and their `template`/`recipients`/`variables` + with a `notify` node against a real messaging service; replace inline + `config.script` with a registered function — it never executed. +- **Standalone validation artifacts:** delete `*.validation.ts` files and the + Studio Validations entries built from them, and move each rule into the + object's own `validations[]`. Nothing authored through that door ever gated a + write — including `state_machine` rules. +- **Job authors:** jobs can no longer be created at runtime or overridden per + org. Move any runtime-created job into `defineStack({ jobs, functions })` so + its `handler` resolves against a real function. Existing `sys_metadata` rows + are left untouched — they were never scheduled — and now report `skipped`. +- **`managedBy` authors:** rename `'system'` → `'system-data'` + (`os migrate meta --from 16` does it; stored rows are converted, never + reinterpreted). The bucket defaults **writable**, so delete now-redundant + `userActions: { create, edit, delete }` blocks and keep `userActions` only to + narrow. +- **App authors:** delete `areas[].visible` and `areas[].requiredPermissions` — + both failed open, so anything you were gating with them was visible to + everyone. Re-gate item-by-item inside `areas[].navigation`, which is now + filtered server-side. Also expect gated items to be **absent** from the + `/meta` payload rather than present-and-hidden. +- **Comment consumers:** `sys_comment` is now gated by the record its + `thread_id` names; `visibility` and `reply_count` are removed. A UI that read + `visibility` to decide what to show should stop — it never decided anything. +- **Automation hosts:** `await` `IAutomationService.getSuspendedScreen(runId)` + and make test doubles resolve rather than return (#4515). +- **Runtime metadata writers (Studio / `/meta` / MCP agents):** an active + **flow** write is now linted and refused with 422 `INVALID_METADATA` when a + gating rule fires. Fix the metadata; `OS_ALLOW_UNLINTED_METADATA_WRITES=1` + buys a migration window and should not outlive it. +- **Datasource and connector authors — do this one first.** Inline credentials + are refused at publish. Move `config.password` / `config.authToken` (and the + alias spellings `passwd`, `pwd`, `token`, `jwt`, `auth_token`, `authtoken`) + and any `user:password@host` userinfo in `config.url` into the secret store, + and reference the handle with `external.credentialsRef` — Setup → + Datasources binds it for you. Do **not** substitute a `${…}` placeholder: + placeholders in authored metadata are resolved by nothing and reach the + database client verbatim, so that form is refused too. Connector descriptors + must drop a non-`none` `authentication` and document the scheme in prose; + instances reference a `credentialRef`. There is deliberately no codemod — + `os migrate meta` reports both as structured TODOs, because auto-deleting the + key would silently drop a live credential. Rows already stored in cleartext + are a separate programme (follow-up under #7990); this release closes the + doors that keep writing new ones. +- **Driver names:** `mongo` → `mongodb`, and pin `OS_DATABASE_DRIVER` to a name + the single vocabulary knows. `os start` and `os migrate` now read the same + table, so an alias one accepted and the other refused (`pg`, `libsql`) is no + longer a coin flip, and the datasource factory can no longer fall through to + `memory` on a name it does not recognise. +- **Memory-driver deployments:** it now declares itself single-tenant and + refuses to boot multi-tenant. It never implemented row-level isolation, so if + this refusal fires, that deployment was serving cross-tenant reads, updates + and deletes silently. Move to a driver in the tenant-chokepoint scan + (`driver-sql`, `driver-sqlite-wasm`, `driver-turso`). +- **API callers:** unknown query parameters are now refused on the first tier of + data read routes rather than dropped. Audit any integration that passes + parameters the route never declared — the tolerated traffic that used to get a + plausible `200` is exactly what this breaks. Also drop `cursor` from + `GET /api/v1/notifications` (it paginated nothing) and stop reading + `config.features.passkeys` / `.magicLink` off `GET /auth/config`. +- **Text filters on MongoDB:** `$contains` / `$notContains` / `$startsWith` / + `$endsWith` are case-**sensitive** now, on this driver and the memory driver + as they already were on SQL. Row sets change. Where you want the fold, use + `$icontains`; where a filter feeds an RLS read scope, note the old behaviour + was over-reach, not merely a loose match. +- **List-view authors:** rewrite `exportOptions: ['xlsx']` to the object form + `exportOptions: { formats: ['xlsx'] }` — the bare array was type-legal and + non-functional, and the renderer fell back to `['csv','json']`. Drop `'pdf'` + (declined platform-side, #1301) and delete `striped` / `bordered` / + `virtualScroll`, which were copied down the chain and applied by nothing. +- **Runtime field creation:** a standalone `PUT /meta/field/{object}.{name}` + now answers 403 `NOT_CREATABLE` instead of a `200` that persisted a row the + object never showed. Write the whole object instead — + `PUT /meta/object/{name}` with the entry in `fields` — which is unchanged and + still supports runtime creation. +- **Package uninstall:** `deletePackage` refuses a call that names neither an + organization nor `allTenants`. Callers that omitted the organization were + deleting every tenant's rows; decide which you meant and say so. +- **Engine callers:** delete `upsert` from `engine.update()` option bags — it + was never implemented. Express create-if-absent explicitly (`findOne`, then + `insert` or `update`), and note that the by-id update branch now throws + `RECORD_NOT_FOUND`. +- **Number fields:** a declared `scale` is enforced by rejection. A field + declared `scale: 0` that has been accepting `11.5` now answers 400 + `VALIDATION_FAILED` with field code `max_scale`. Nothing rounds — check your + ingest paths, CSV import included, before upgrading. +- **Paused-node executors and `api` authors:** a pause node whose descriptor + never declared `resumeAuthority` is now fail-closed on the generic resume + route — declare `'any'` to opt back in. `api` is code-only: move any + runtime-created endpoint into `**/*.api.ts` or `defineStack({ apis })`. +- **Audit-log consumers:** `export`, `permission_change` and `restore` leave the + `sys_audit_log` action enum — none ever had a writer. In the other direction, + expect **new** rows you were not seeing before: sign-in/sign-out, settings + `config_change`, the full metadata lifecycle, and package-publish rows + including refusals. +- **Everyone:** run `os validate` before upgrading. The ADR-0078 completeness + rules are new **errors**, and they fire on metadata that has been parsing + cleanly for majors — a `summary` field with no `summaryOperations`, a + `formula` with no `expression`, a relationship with no reference, a + `select`/`radio` with no options. Each finding is a field that has been + computing nothing. + + diff --git a/content/docs/releases/v17/17.1.mdx b/content/docs/releases/v17/17.1.mdx new file mode 100644 index 0000000000..82e8f3c5ee --- /dev/null +++ b/content/docs/releases/v17/17.1.mdx @@ -0,0 +1,720 @@ +--- +title: 17.1.0 +description: "Release notes and upgrade checklist for 17.1.0 of the v17 line." +--- + +## Highlights — 17.1.0 + +- **Deactivating a permission set or a position now actually stops granting + access.** Both objects shipped a Deactivate action whose dialog promised, in + all four locales, that access stops — and nothing read the column: a position + seeded `active: false` still granted its permission sets, and a permission set + seeded `active: false` still returned `posture: PLATFORM_ADMIN` (#8613). A + sharing rule reached users by a second road that never passed that seam, so a + rule sharing records with a deactivated position kept sharing them (#8710). + Both are closed. ⚠️ **On a deployment that used the switch believing it was + inert, this is a real revocation on live data** — read the upgrade checklist. +- **Platform admin sets stop shipping a bulk-export wildcard.** + `admin_full_access`, `organization_admin` and the derived + `organization_admin_no_bypass` carried `objects['*'].allowExport = true`, which + made the 17.0 export axis undeniable: an application could declare an object + exportable by nobody and the platform exported it anyway (#8681). Export is now + granted per object, in an app's own permission set. +- **Partial field masking.** `FieldSchema` declares `maskingRule` — a closed + preset enum (`phone`, `id_card`, `bank_account`, `email`, `name`) plus a + `{ keepHead, keepTail }` escape hatch — served masked-but-recognisable + (`138****5678`) to every non-system caller, with `requiredPermissions` as the + unmask gate (#8993). +- **`sys_audit_log` can answer "who viewed this record".** The ledger covered + writes only; it gains a `read` action, its writer, and a `record_views` list + view. Record-detail reads only, per-object opt-in, batched off the request + path, and never any field values. +- **All three flow doors answer the same status table.** A refused dispatch stops + reporting a failed run: `404` not found, `409` `FLOW_DISABLED`, `422` + `FLOW_NO_START_NODE`, `400` `FLOW_FAILED` for a run that actually ran and was + rejected. The automation `trigger` routes (#9378 line), the actions door + (#9446) and declared `type: 'flow'` endpoints (#9462) now read one shared + definition instead of three private copies. +- **`error.code` is a closed vocabulary at every door** (#9106). A thrown code + outside `StandardErrorCode ∪ ERROR_CODE_LEDGER` no longer reaches `error.code`; + it rides the new optional `declaredCode` instead, so an app's own spellings + survive without widening the platform vocabulary. +- **Console:** two objectui pin moves (`665661ab0932 → 82a94170c405 → + 9a3daf8d37ad`) bringing dashboard component re-keying, the retirement of the + structured `confirm` object on actions, `I18nLabel` on metric widgets, and + host-performed `submitBehavior.url` redirects for consoles mounted at a + sub-path. + + +## What's new in 17.1.0 + +17.1.0 was published to the `latest` tag on **2026-08-20**, six days after the +17.0.0 GA. It is a large minor: the version-locked train moved **69 packages**, +carrying **111 minor and 203 patch changelog entries and no major**. The bundled +Console advances two pins, `665661ab0932 → 82a94170c405 → 9a3daf8d37ad`. + +⚠️ **Read this before treating the version number as a safety guarantee.** A +minor adds capabilities without breaking existing metadata or code — but several +changes here landed *after* the 17.0.0 cut and ship as `minor` under the lockstep +launch-window convention while being explicitly breaking, and they say so in +their own changelog entries: the export-wildcard withdrawal (#8681), the strict +close of the top-level stack surface (#8687), the dashboard `modal` target +narrowing (#9013), and the list-view `sort` refusal (#9257). Two more — +`sys_permission_set.active` / `sys_position.active` (#8613) and the sharing-rule +recipient filter (#8710) — change who can read and write on **live data with no +migration step to notice**. The 17.1.0 upgrade checklist below is not optional +reading for this release. + +The practical theme is *honest refusals*. Where 17.0.0 made the authorable +surface loud, 17.1.0 makes the runtime's answers loud: a flag that promised to +revoke access now revokes it, a read that failed stops being served as an empty +one, a flow that never dispatched stops being reported as a run that failed, and +a credential that was never meant to be readable stops being served. + +### New capabilities in 17.1.0 + +- **Partial field masking (#8993).** `FieldSchema` declares `maskingRule` — the + 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 change (ADR-0049 declare = enforce). A + field declaring a rule is served masked-but-recognisable to every non-system + caller, and the field's `requiredPermissions` (ADR-0066 D3) is the unmask gate. + 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 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` rather than silently overwriting the stored value. New + exports: `FieldMaskingRuleSchema`, `FieldMaskingKeepSchema`, + `FIELD_MASKING_PRESETS`, `maskFieldValue`, `MASK_CHAR`. +- **Record-view auditing.** `sys_audit_log` covered writes only, so *who viewed + this customer record, and when?* had no answer short of custom work. The ledger + gains a `read` action, its writer, and the `record_views` list view. Scope is + deliberately narrow: **record-detail reads only** (the read 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 with no global switch** + (`new AuditPlugin({ readAudit: { objects: ['contact', 'account'] } })`; an + empty opt-in registers no hook, so the default posture costs a read nothing), + and **batched off the request path** — rows persist on a later tick and keep + the instant the record was viewed, not the instant its batch drained. The row + records who, what and when and **never field values**: read auditing runs ahead + of field masking, so copying values in would mint a plaintext copy of exactly + what field-level security withholds. A system-elevated read (`api.sudo()`, + formula recomputes, roll-ups) and a read with no principal both write no row. +- **Read-only approval visibility, per object, default OFF (#8652).** + `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. Omitted or empty leaves visibility exactly as + it is, so an existing deployment sees no change on upgrade — but **on an object + you list this is not a no-op**: a ledger keeper or supervisor who holds full read + but never appears in the approval used to get `200` with an empty list, and now + sees the request row (including its `payload` snapshot), every actor and + decision, **the action's comment text** (意见正文), and decision attachments. + Enable it where the approval commentary is meant to be seen; it is per object + precisely so enabling it for a ledger object does not enable it elsewhere. The + tier is read-only (`can_act: false`, and no approval action is delivered + through it), introduces no new permission concept — the service reads the + record **as the caller**, so ordinary CRUD and RLS decide — and leaves an + untargeted inbox list unchanged, so a work queue does not become a browse + surface. +- **`os migrate duplicates` (#8928).** An operator-facing, read-only inventory of + the business identifiers the tenancy split already minted twice. It reports one + row per duplicated value with its holders (id, organization, partition, + creation timestamp), the live condition (an object still running a `__global__` + counter beside an organization-scoped one), and a `skipped` list carrying the + driver's own message so an unreadable target never reads as a clean one. JSON + on stdout, no persistence, no new schema, and it applies nothing — no DDL, no + seed. ⚠️ **Run it before the #8686 backfill:** `organization_id = NULL` is the + marker that says the row came from the untenanted side, and the repair + overwrites it. Reporting is all it does — renumbering an already-minted + identifier stays deliberately out of scope. +- **Operator-initiated re-homing of stored cleartext datasource credentials + (#8155).** A datasource row created before the write door closed can still hold + its credential in cleartext inside `config`; the read paths were closed, but + nothing removed what was already at rest. `IDatasourceAdminService + .migrateCredential(name)` — the Setup action *"Move credential to the secret + store"*, backed by `POST /api/v1/datasources/:name/migrate-credential` — writes + the secret, **reads it back and compares**, and only then binds + `external.credentialsRef` and drops the inline key in one record write. Per + datasource, initiated by an operator, never a sweep and with no batch spelling. + It is idempotent, and it refuses rather than guesses: a credential embedded in a + connection URL, an alias spelling no connection builder reads, turso's + `encryptionKey`, and a code-defined datasource are each refused with a reason, + and any credential-shaped key left behind is named in the result so "migrated" + never reads as "this row is now clean". +- **A hook refusal can mark its own message user-facing — `userMessage` (#9934).** + The console form deliberately discards the server `message` on 403 and + substitutes a generic string, which also suppressed every deliberate, localized + refusal an application hook author wrote. Setting `userMessage` on the thrown + error at throw time marks that text as user-facing. It is a text-carrying field + rather than a boolean beside `message`, so no boundary that rewraps or + substitutes `message` can promote platform prose into the marked channel, and + platform/driver code never sets it. `ApiErrorSchema.userMessage` and + `EnhancedApiErrorSchema.userMessage` are additive and optional; unmarked errors + produce byte-identical wire bodies. The SDK attaches `err.userMessage` from both + wire dialects. +- **`ActionSchema.onSuccess` — post-success navigation (#9566, #9474).** One + contract for both server-executing action types: `onSuccess: { navigate, openIn? }`, + 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). + `navigate` is a route/URL template whose interpolation scope gains + **`${result.*}` — the action's server response payload** — which is what makes + "server clones a record, jump to the new record" declarable: + `navigate: '/apps/crm/tasks/${result.id}'`. `openIn` is the closed enum + `'self' | 'newTab'`, defaulting `'self'`. The existing handler-return convention + (`{ redirectUrl, openIn? }`) keeps its 17.0.0 semantics, so no shipped handler + silently flips behaviour. +- **Localized email templates reach the inbox and flow `notify` nodes.** + `IEmailService` gains a render-only + `renderTemplate({ template, locale, data, timezone }) → { subject, html, text }`, + and the inbox channel consumes it, so localized `sys_email_template` content now + reaches `sys_inbox_message` (#9225). Flow `notify` nodes can reference a template + for localized delivery — `template` + `templateData` on `NotifyNodeConfig`, + resolved by `(name, locale)` at delivery time (#9205). **The locale is one + value for the whole notification, not one per recipient**: `payload.locale` if + the producer set one, else the deployment default + (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so + recipients whose personal languages differ all receive the same row (a + per-user locale is deferred by the 2026-08-13 ruling). +- **`map` joins the per-visualization blocks on `ListViewSchema`.** + `ListMapConfigSchema` is the eighth block alongside kanban / calendar / gantt / + gallery / timeline / chart / tree, declaring the map renderer's documented read + surface: `latitudeField`, `longitudeField`, `locationField`, `titleField`, + `descriptionField`, `zoom` (1–20) and `center`. All keys are optional and none + carries a default — with no camera declared the renderer fits the camera to the + queried records. Because `ListViewSchema` is strict, a `type: 'map'` view could + not declare its field mapping at all before this, so any marker title field + other than the renderer default `name` was unreachable. +- **`icontains` joins the view and infix filter vocabularies (#8934).** The + operator has been executable on every driver since #5702/#6520 but was + authorable from only one of the three filter dialects. `VIEW_FILTER_OPERATORS` + and `AST_OPERATOR_MAP` gain the canonical spelling. The boundary is preserved + deliberately: `icontains` (LIKE-escaped substring — a comparand `%` is a + literal) and `ilike` (raw LIKE pattern) are **not** aliases of each other, and + there is no `not_icontains`, because the authoring vocabularies mirror the + executed set rather than widening it. +- **Field-level `relatedListFilter` (#8704).** The field-level related-list family + (`relatedList` / `relatedListTitle` / `relatedListColumns`) gains a fourth + member, 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. It carries the canonical Query-DSL + `FilterCondition` — no new dialect — and is AND-composed with the + parent-relationship condition as an authored constraint, with the tab badge + count honouring the same composed filter so counts match visible rows. + ⚠️ The consumption half (objectui#4664) had not landed at this pin: the key is + ledgered `planned` and carries an author warning until it does. +- **Stamp-only `tenancy.organizationField` (#8778).** The platform could say what + an object is *walled* by (`tenancy.tenantField`) but not which column says who a + row is *about* — which for credential tables deliberately differ. + `TenancyConfigSchema` accepts an optional `organizationField` that is + **read-neutral and stamp-only**: the audit writer consults it, and + `applyTenantScope`, `injectTenantOnInsert`, `computeTenantLayer0Filter` and + `resolveInjectedSystemColumns` are measured blind to it, pinned by tests. + Declaring it never walls an object and never hides rows. `sys_api_key` now + declares it, so revoking a key from a different active organization lands the + audit row behind the wall of the *key's* organization. +- **API keys are minted against the minter's active organization (#8287).** Under + `OS_TENANCY_POSTURE=isolated` a minted key could read **nothing at all**: + `sys_api_key` carried no organization column, so key authentication established + a user but no active organization, and every organization-scoped read answered + `200` with `total 0` while the console went on offering minting. `sys_api_key` + now declares `active_organization_id`; `POST /api/v1/keys` **inherits** the + caller's active organization (there is deliberately no org parameter and no + cross-org key) and re-checks `sys_member` membership at mint time. An + ex-member's key now fails closed at verify time rather than degrading to a + user-only principal. ⚠️ Pre-existing org-less keys are **never backfilled** — + they keep working under `single` and `group`, and are refused under `isolated`, + where they are provably dead today. +- **`storage` becomes the canonical `CoreServiceName` slot (#9683).** + `file-storage` was the only member whose spelling diverged from its documented + accessor (`services.storage`). `storage` is now canonical; `file-storage` stays + an accepted, **deprecated v17 alias** — `@objectstack/service-storage` registers + the same instance under both names, and discovery reports the canonical key and + mirrors the row verbatim under the alias for its v17 lifetime. A custom v17 + provider for this slot should register both names. +- **Smaller authoring additions.** `Field.time` closes the temporal-builder gap — + `'time'` was a fully declared `FieldType` with no builder, splitting the three + temporal types two-and-one (#8656). `placeholder` becomes a declared + `FieldSchema` key, ending the preview-renders/save-422s trap where four shipped + objectui render surfaces applied the key and `PUT /api/v1/meta/object/:name` + refused it (#9019). `security.explain()` accepts the `recordIds` batch spelling + (#8480). `IHttpServer` gains an optional `afterResponse` response-observing + hook. `GanttConfigSchema` declares `viewMode`. + +### Security corrections in 17.1.0 + +The largest cluster in this release, and the reason its minor version number is +not a safety guarantee. + +- **`sys_permission_set.active` and `sys_position.active` now actually stop + granting access (#8613).** Both objects ship a Deactivate action whose + confirmation dialog promises, in all four locales, that access stops. 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. `resolveAuthzContext` / `resolveUserAuthzGrants` now drop a + deactivated row **before** any derivation, so a deactivated position + contributes no grants and its name leaves `positions`, and a deactivated + 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). Both tables were already read at that seam, so + this costs zero new hot-path queries. A row whose `active` column is **absent + or NULL is unaffected** — the predicate is "explicitly deactivated", never + "explicitly active". Enforcing the flag also opened a one-click, + installation-wide lockout (deactivating `admin_full_access` un-makes every + platform admin at once, and re-activating requires the permission the click + just took away), so the last-administrator guard now judges that write like the + delete and rename spellings it already refused. +- **A deactivated `sys_position` stops conferring sharing-rule record shares + (#8710).** #8613 closed the authorization *derivation* seam; a sharing rule + reaches users by a second road that never passes it, so a rule sharing records + with `cfo` kept sharing them after `cfo` was deactivated. Maintainer ruling, + 2026-08-15, verbatim: **"Access-conferring paths filter deactivated positions; + addressing paths do not."** A rule whose recipient is an explicitly deactivated + position now expands to nobody, and the shares it had already materialised are + **revoked on the next reconcile**. Deliberately unchanged, per the same ruling: + approval **routing** keeps reading the raw directory (filtering there is + fail-open — an approval step routing to nobody), and write gates and + blast-radius reads stay unfiltered, because dropping a deactivated row there + would make a refused binding permitted — access *widening*. +- **The shipped admin permission sets no longer grant export on the `*` wildcard + (#8681).** Measured on 17.0.0 GA across 40 export probes, 5 principals and 8 + objects with real bearer tokens: an org owner exported `crm_quote`, + `crm_campaign` and `crm_task` with `200` and full data, while no app permission + set granted export on any of them and the app had no way to say no — the + wildcard lives in code-package metadata, so editing it answers `403 + [not_overridable]`. This was never a gate defect; the same run proves the gate + exact for every other principal. READ is untouched — this narrows bulk egress + only, and `allowExport` on a `'*'` entry remains a supported authoring shape in + an app's own sets. +- **Credentials and secrets stop being served.** The metadata read path no longer + serves stored cleartext credentials (#8154), and `GET /api/v1/meta/:type/:name/diff` + stops serving stored credential values. `sys_account`'s OAuth access/refresh/id + tokens and its `password` / `previous_password_hashes` stop serializing on the + data API (`internal: true`). Custom email headers stop being served through the + generic data-API read of `sys_email` (#8149). +- **Doors that answered anonymously now require a principal.** The + external-datasource federation HTTP family requires an authenticated caller on + every route (#9686); the datasource-admin family requires authentication + (#9391) and then the `manage_platform_settings` capability; + `POST /meta/:type/:name/publish` and `.../rollback` require `manage_metadata` + (#8919); the four mutating `install-local` routes require `manage_metadata` and + drop the `x-user-id` header fallback, and the `install-local` listing requires + an authenticated principal; and the four `/admin/sso/*` bridges run the + ADR-0068 platform-admin gate before delegating into better-auth. The REST and + endpoint-route 401 anonymous-deny bodies now carry `code: "UNAUTHENTICATED"` + alongside the existing `error` / `message` keys (#9487, #9823). +- **Audit attribution is the authenticated identity.** On all five `/meta` write + doors the recorded actor is now the authenticated identity and the `X-Actor` + header is ignored. Audit rows are stamped from the record's own organization + rather than the actor's active one (#8707), REST `/meta` write doors carry the + caller's organization so rows are no longer stamped environment-wide, and the + metadata audit read is scoped to the caller's organization (#8747). +- **Caller values stop reaching the server log.** A policy-authored + `INVALID_FILTER` refusal withholds the target field (#8197); four more + diagnostic families stop logging the caller's value, measured off live MySQL 8.0 + and PostgreSQL 16; a caller value containing `" - "` no longer eats the + diagnostic's template head; MySQL duplicate-entry reports keep the value out; + and `publish-drafts` no longer discloses driver or subscriber text on + `unhideError` / `rebindError` (#8516). +- **Two ADR-0055 `controlled_by_parent` corrections.** The master-editability + check consults the same app-authored write widener the by-id path does, and + detail writes compose the master's ownership floor the same way a direct write + does (#8865). ⚠️ The first of these is flagged in its own entry as **widening** + who may write rows that are refused today. An insert that omits a required + master-detail parent now answers `400 VALIDATION_FAILED` with `fields[]` rather + than a security-shaped refusal, and security explain reports partial masking as + a third state instead of calling gated fields hidden. + +### Behavior changes & fixes in 17.1.0 + +#### The flow doors answer real HTTP statuses + +- **The automation `trigger` routes** now answer `409` `FLOW_DISABLED` for a + disabled flow and `422` `FLOW_NO_START_NODE` for a definition with no start + node, instead of `200` wrapping an inner `{success: false}`. `AutomationResult + .code` gained the two members deliberately — rather than the transport guessing + from message text — and they carry **no** `status`, which is what lets a + transport tell a never-dispatched exit from a run that dispatched and failed. + Two statuses and not one because a disabled flow is reversible operational + state (`409`) while a definition with no start node cannot be executed as stored + and no retry helps (`422`). +- **`POST /api/v1/actions/:object/:action`** answers that same table instead of + one blanket `400 FLOW_FAILED` (#9446), and **declared `type: 'flow'` endpoints** + join it (#9462) — that door used to answer `200` for every outcome with the raw + engine result in `data`, so a client branching on HTTP status read every + failure as a success on the surface an app publishes as its own public API. An + `outputMapping` is no longer applied to a failure, closing a hole where a + refused dispatch could be presented as data. All three doors now read one + definition (`packages/runtime/src/flow-dispatch-status.ts`). +- **The resume route closed its envelope.** `POST /api/v1/automation/:name/runs/:runId/resume` + refuses a body carrying an unknown top-level key — the accepted set is exactly + `inputs`, `variables`, `output`, `branchLabel` — with `400 VALIDATION_FAILED` + naming the offending keys. A body like `{"nodeId":"ask","values":{…}}` used to + answer `200 success:true` with the screen submission treated as empty: the run + completed and the submitted value never reached the flow. A sibling change + refuses an accepted key carrying a value of the wrong type. +- A flow ACTION that ran and failed now carries the flow author's `errorMessage` + and the run `summary` in `error.details`, and a triggered run carries the + author's `successMessage` / `errorMessage` through `execute()` and both retry + exits. + +#### A failed read stops reading as an empty one + +A recurring class this release closes in several packages at once: a read that +FAILED was indistinguishable from a read that legitimately found nothing. + +- **Four metadata-protocol seams (#8896).** `SeedLoaderService.loadExistingRecords()` + returned an empty `Map` — which is not a cache but the write decision, so the + upsert pre-load turned every update into an INSERT and `bulkWrite`'s retry + recheck was silently disarmed. `searchAll()` reported `totalObjects` / + `totalHits` / `truncated` as though a partial scan had been complete. + `findReferencesToMeta()` dropped whole source types from the list that answers + "what would break if I delete this", so a short list read as "nothing depends on + it". `publishPackageDrafts()` pushed a **fabricated** revert-plan entry whose + `existedBefore: false` means "revert = soft-remove", so reverting that commit + would DELETE 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 shared `isMissingTableError` predicate, so an unprovisioned table + stays benign and everything else surfaces. +- Siblings across the tree: `getMetaDiagnostics` stops publishing an unreadable + store as "0 problems" (#8855); `auditMetaItem` stops reporting a failed audit + read as an empty trail, and a missing `auditMetaItem` / `findReferencesToMeta` + capability is refused rather than answered as "no audit trail" / "nothing + depends on this" (#9426, #9326); `diffMetaItem` stops serving a history outage + as an empty diff (#8868, #8833); a failed `sys_organization` probe stops reading as + "this install has no organizations" (#9261); the roll-up summary index's failed + read is never cached as an empty index (#9154); a cascade-delete dependents + probe that FAILS no longer skips the referential guard; `ObjectQLPlugin`'s three + registry reads stop inventing an empty registry (one silently skipped schema + sync); a failed governance row-count probe stops being indistinguishable from a + quiet object; and `GET /api/v1/meta/:type` refuses a type name that names + nothing instead of serving it as an empty collection (#9488). + +#### Author-time gates reach the runtime publish door + +Rules that only `os build` / `os validate` ran now also judge a runtime write, so +Studio and the metadata API cannot land what the CLI refuses. + +- **Dashboard writes are judged by `validateWidgetBindings` (#7529)** — a widget + bound to a dataset that resolves to nothing was previously a `200` on the + publish door. All six of the rule's error-tier findings gate a publish as one + reference-integrity class. +- **The five gating object rules cross the publish gate**, so an `object` write + through the metadata API is judged by the same completeness, managed-`apiMethods` + and autonumber rules `os build` runs. +- **The publish gate judges a package write against that package's own closure + (#9612)** — it used to hand every rule the tenant's entire `objects` collection, + asking a question nobody wanted answered. The unit is now the package plus the + transitive closure of its **declared** `manifest.dependencies`, plus platform + objects unconditionally, plus rows carrying no package provenance. A write that + names no package narrows nothing and is judged exactly as before — an + unresolvable package buys a write *more* validation input, never less. +- **The publish door reports the runtime authoring gate's advisory findings** + (#9176), and `publishPackageDrafts` (Studio's "publish whole app") reports them + too; the batch publish response gained a spec contract (#9406). +- **New authoring-time refusals.** A dashboard header `modal` action's target + resolves against declared PAGES only (#9013) — `os validate` previously blessed + exactly the buttons the runtime refuses. A sharing rule anchored where sharing + has nothing to widen (a `public` effective sharing model, or a master-detail + detail) is an error, with separate ids because the repairs differ (#9698). A + list-view `sort` naming a formula field, or no field at all, is refused (#9257). + A bare date-range preset name in an ordering filter comparand is refused at + publish (#8793). Three write-surface rules ask provenance, not just membership, + before exempting a system column (#8663), and the same provenance question is + asked at a fifth blanket-`SYSTEM_FIELDS` read site, `searchableFields` (#8404). + +#### The authorable surface closes further + +- **Unknown top-level stack keys are refused (#8687)** — `ObjectStackDefinitionSchema` + was the last strip-mode surface of the #4001 campaign. Measured on 17.0.0 GA, + three injected bogus top-level keys added **zero** warnings to `os validate` and + exited 0, even under `--strict`. The failure population is a typo or stale key + (`flow` for `flows`, `approvalProcesses` after its removal) shipping an artifact + with a whole metadata family absent at runtime. Near misses carry a did-you-mean, + and curated prescriptions answer the known retirements (`storage`, `approvals`, + `workflows`, `portals`, `onDisable`). `onEnable` is now declared rather than + undeclared-but-honoured. +- Undeclared keys are refused on the **analytics authoring surface** (#4001 batch + D), on object **`indexes[]`** entries, on **`Field.inlineColumns` / + `Field.relatedListColumns`** (#9227), and on **`record:alert` / + `record:quick_actions` / `record:history` / `record:discussion`** (#8744) and + **`record:reference_rail`** (#8691) in `ComponentPropsMap`. Malformed field + `scale` / `precision` declarations are refused at authoring time (#8321), and the + closed vocabulary of expression-bindable text keys is declared (#9599). +- **ADR-0049 retirements.** The `element:filter` element retires at element grain + (#9220); the inert `targetVariable` key leaves `element:text_input` and + `element:record_picker` (#9198); `MetadataPluginConfig` loses the inert + `additionalTypes` (#8586); `ApiKeySchema` retires — the identity module stops + publishing a second, fictional declaration of `sys_api_key` (#8715); + `sys_user_permission_set` retires the `delegated_from` column (#9730); and + `BATCH_PARTIAL_FAILURE`, `BATCH_COMPLETE_FAILURE` and `TRANSACTION_FAILED` leave + `StandardErrorCode` (ADR-0112 amendment, 2026-08-18). + +#### Datasource credentials reach the driver + +- A bound `external.credentialsRef` now reaches the **mongo** client on a URL + branch, the **mysql** client on the DSN branch (#8696), and the **postgres** + server on the DSN branch, not just the knex config (#8873). A mysql datasource + that declares TLS now gets it, on both branches and in the spelling `mysql2` can + read (#8874). +- Publish-time refusals close the authoring half: a credential in the mongo + options passthrough (`config.options.auth.password`, #9040); + credential-bearing URL query parameters (`?authToken=` / `?password=`, #8337); + a postgres `config.url` that `pg` itself cannot parse (#9091); `${…}` placeholder + syntax in memory `persistence.path` / `persistence.key` (#8495); and the + contradictory pairs "`credentialsRef` bound + a composed mongo config naming no + `username`" (#9147) and "`credentialsRef` bound + a mongo `config.url` naming no + user" (#9041). + +#### Drivers, query engine and analytics + +- **One unresolvable WHERE column, one answer.** `find()` and `count()` both + refuse with `INVALID_FILTER` / 400 naming the column, and **MySQL joins the + predicate** with the full refusal envelope and recoveries (#8926). +- **MySQL upserts stop merging onto rows the caller never identified.** An upsert + whose `conflictKeys` no PRIMARY KEY or UNIQUE index backs is refused (calls that + previously "resolved" now fail loudly), an upsert whose named conflict target + another UNIQUE key can absorb is refused (#8755), a merge that would rewrite the + row's primary key is refused and rolled back (#8622, #8807). +- **MySQL metadata locks stop hanging.** A blocked `os migrate` refuses in 120s + instead of hanging effectively forever (#9354), and boot schema-sync's widening + ALTER bounds its lock wait too — a blocked boot warns and carries on, and an + un-run widening ALTER is reported at `error` naming the fix. +- **Cascade delete and multi-value references.** `cascadeDeleteRelations` probes a + `multiple: true` reference field with a spelling its storage can answer; the + required-FK escalation on a `multiple: true` lookup is judged **per row**, so a + parent delete is refused only over the rows that actually hold it; + `deleteBehavior: 'set_null'` on a `multiple: true` reference removes the deleted + **member** from the stored array instead of nulling the whole field; and the + delete-cascade path's registry reads propagate instead of answering "no + relations" (#9002). +- **Undeclared fields are refused at the schema** on insert (#8682) and on update + before `beforeUpdate` hooks run (#8738). `[]` no longer satisfies `required` on a + multi-value field (#9476). A TRUE `readonlyWhen` no longer strips hook-derived + values — the conditional strip judges only API-boundary callers (#9107). A + temporal filter comparand the platform cannot interpret is refused at the engine + door instead of answering `200` with zero rows, and a scalar `in`/`nin` + comparand no longer reaches a driver (#9228). +- A dotted filter key whose head is a relation, a formula or a plain scalar is + refused **at both doors** (#8371), and unscoped `multi: true` write + refusals are restored through the wired engine on `sys_attachment` (#9719) and + `sys_comment`. + +#### CLI & developer experience + +- **`objectstack init` scaffolds now compile (#9666).** `init … --install` + reported `✓ Scaffold validated` and the next documented step, `npm run dev`, + failed to compile: the CLI's own shipped template was refused by the CLI's own + shipped rule set, because the `app` and `plugin` templates declared no + `sharingModel` (OWD). Both templates now declare `sharingModel: 'private'` — the + rule's recommended default and the ADR-0090 D1 baseline — and `init`'s self-test + now runs the author-time rule registry over the generated project instead of + only checking that the config loaded. The rule set is the `build` one, the same + set `os dev` reaches, so this is a shift-left rather than a stricter bar. +- **The ready banner names what actually booted (#8978).** On an `OS_ARTIFACT_URL` + boot the `objectstack.config.ts` in cwd is deliberately never executed, but the + banner's `Config:` row still printed it; on the plain artifact-fallback path it + named a config file that does not exist on disk at all. `serve` now reports the + resolved artifact's already-redacted `display` string in an `Artifact: … + (OS_ARTIFACT_URL)` row, omits the row where no safely-redacted value is in hand, + and reports the authored config exactly as before on an ordinary config boot. +- **`os start` / `os dev` stop writing `OS_ARTIFACT_PATH` into the child `serve` + environment (#8985).** Both supervisors handed the resolved path down through + the same variable an operator sets, before the downstream `objectstack.config.ts` + is evaluated — so a config could not answer *"did a human ask for this, or did + the CLI put it here?"*. The path now travels on `OS_INTERNAL_ARTIFACT_PATH`, a + channel the CLI owns both ends of, restoring the property that the presence of + `OS_ARTIFACT_PATH` in a config's environment means an operator set it. Nothing + about resolution changed: + `--artifact > OS_ARTIFACT_URL > OS_INTERNAL_ARTIFACT_PATH > OS_ARTIFACT_PATH > /dist/objectstack.json`. +- `os init` template descriptions stop advertising metadata kinds they never emit + (#9737); scaffolded projects stop describing themselves as the blank template + (#9263); the scaffolded Dockerfile pins the runtime image to the CLI that builds + the artifact instead of `latest` under a comment saying to pin (#9017); the blank template's `specVersion` + stops shipping eleven majors stale (#9264); and the five remote content templates + retire from the scaffolder's catalog. +- `os migrate meta --from N` can finally open the retired-key sources it exists to + rewrite (#9418); `os meta resync` explains a nonzero skip count instead of + leaving it to look like a no-op (#9184); `serve` warns when the declared replica + count exceeds the licensed node cap — ⚠️ deliberately advisory wording, because + nothing is actually refused yet (#8504); and `objectstack serve` registers + `ObservabilityServicePlugin`, so `observability:metrics` resolves for every + consumer following the canonical resolution chain. + +#### Observability, packaging and published docs + +- `http_requests_total` and `http_request_duration_ms` are emitted from the + transport seam, so every inbound mount is counted (#9650). `/discovery` + describes the request's environment rather than the control plane (#9292), and + its `mcp` advertisement follows the request's environment too. A zero on + `cache_*` is documented as meaning "no configured consumer", not "no cache + activity" (#9954). +- The plugin-mounted Hono error paths answer the declared envelope — six refusal + bodies stop speaking the pre-#3675 dialect (#9364) — and the hono adapter's and + the dispatcher's two discovery bodies join the response envelope (#9436, #9813). +- A broad published-README accuracy sweep: five service READMEs, four more + READMEs, `driver-sql`, `mcp` and `plugin-audit` stop documenting symbols, APIs + and call sites that do not exist (#9532, #9544, #9867, #9579), and published + READMEs link to the docs site in the one form that works on npm, on GitHub and + on the docs site (#9632). The better-auth family moves off the `1.7.0-rc.2` + prerelease onto stable `^1.7.1`. + +### New in Console (Studio) — objectui pin `665661ab0932 → 9a3daf8d37ad` + +Two pin moves in this release (`665661ab0932 → 82a94170c405`, then +`82a94170c405 → 9a3daf8d37ad`). Notable declared changes: + +- **Breaking (published API).** `app-shell` is no longer a component key; + `NavigationRenderer` no longer accepts `resolveGroupLabel` / `resolveItemLabel` + (app-navigation localization is owned by the server-side `/meta` boundary); + `` retires the legacy `description` prop, leaving `subtitle` as the + only spelling; `dashboardComponents` is re-keyed from 11 PascalCase component + class names to the 8 schema `type` keys the package actually registers; and the + structured `confirm` object on actions is retired in favour of `confirmText` + (objectui#4314, ADR-0049). +- **Modal targets name pages.** Both of objectui's copies of the prefix + convention retire — a `type: 'modal'` action's string `target`, and a dashboard + header `modal` action's `target`, each name a PAGE only. This is the renderer + half of the `os validate` narrowing above; the two repos now agree. +- **Declared surfaces catch up to the spec.** The five `@objectstack/spec` 17.0.0 + keys the renderers already honoured are published so authors can discover them; + `element:text.content` and `element:button.label` declare the inline + translation map they already accept; `ComponentInput.type` can declare a union; + `ObjectMetricWidgetProps` speaks `I18nLabel` on `label`, `trend.label`, + `description` and `title`; `ObjectMapSchema` declares what ObjectMap reads, with + the `map` block outranking the flat spelling; `AuthInvitation.status` becomes + the closed four-member union it always documented. +- **Keys that declared nothing are settled.** `AccordionItem` and + `ToggleGroupItem` each retire `icon` and wire `disabled`; `VectorFieldMetadata + .indexed` / `.distance_metric` and `BaseFieldMetadata.indexed` are removed along + with the field designer's `Indexed` toggle, because the ObjectStack spec has no + field-level index flag; and the retired `striped` / `bordered` / `virtualScroll` + list-view surface is removed. +- **Rendering and data fixes.** `FormSchema.onChange` — a declared callback the + form renderer never invoked — is called (objectui#4259); an option colour + authored as an explicit hex renders as authored instead of being quantized to + nine palette families (objectui#5141); `ObjectGrid`, `bridgeListView` and + `ObjectDataTable` read the declared column spelling and resolve column identity + before handing columns to the table; form-field type resolution no longer falls + back to `ui`-namespace SDUI node renderers; a top-level `conditionalFormatting` + no longer reaches the kanban view; `DatasetReportRenderer` stops reading + `filter` as an alias for `runtimeFilter`; a clicked cartesian mark names its own + series and the drill title reads its label; and a null second-dimension group is + drawn rather than carrying its measure invisibly. +- **Navigation and shell.** A form's ruled `submitBehavior.url` redirect can be + performed by the HOST, so a destination stays inside a console mounted at a + sub-path (objectui#4989); the declared nav `runAction` slot is consumed and the + private `?runAction=` string convention retires; and the current organization + shows in the top bar for users with exactly one membership. + +--- + + +## Upgrade checklist + +⚠️ One checklist per release, for the release you are landing on **and** every release you cross to get there — and see [how far each list has actually been walked](/docs/releases/v17#upgrade-checklists). + +### 17.1.0 + +⚠️ Despite the minor version number, four of these are behaviour changes on live +data or on a published wire contract. Work through them before upgrading. + +- **Audit your deactivated permission sets and positions FIRST (#8613, #8710).** + Any `sys_permission_set` or `sys_position` row currently carrying + `active: false` **stops granting the moment this lands**, and a deactivated + position named as a sharing-rule recipient has its `sys_record_share` rows + **revoked on the next evaluation**. That is the correct direction — it is what + the Deactivate dialog always said — but on an installation that used the switch + believing it was inert it is a real revocation, with no migration step to + notice. List them 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]] + GET /api/v1/data/sys_sharing_rule?filters=[["recipient_type","=","position"]] + ``` + + Rows whose `active` column is absent or NULL are unaffected. +- **Grant `allowExport` explicitly wherever admin export is intended (#8681).** + The platform admin sets no longer carry the `'*'` export wildcard, and nothing + fails at parse time — a deployment that upgrades without editing anything is + valid metadata whose administrators have quietly lost export on every object no + app set names. There is deliberately no automatic replacement. Author it in + **your** app's permission set (platform sets are not overridable): + + ```ts + { + name: 'system_admin', + objects: { + crm_account: { allowRead: true, allowExport: true }, // export intended + crm_quote: { allowRead: true }, // export withheld + }, + } + ``` + + 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. +- **Re-run `os build` / `os validate` after upgrading.** Several new refusals can + fail a stack that built clean on 17.0.0: an unknown **top-level stack key** + (#8687 — check for a typo or a stale key such as `flow` for `flows`, which until + now shipped an artifact with that whole family absent at runtime); a dashboard + header `modal` action whose target names an action, a bare object or a + `_` form rather than a declared **page** (#9013 — those buttons + already dispatch to a refusal at runtime; there is deliberately no automatic + rewrite, and `os migrate meta` surfaces it as a structured TODO); a **sharing + rule** anchored on an object whose effective sharing model is `public` or which + is a master-detail detail (#9698); a list-view **`sort`** naming a formula field + or no field at all (#9257); a bare **date-range preset** in an ordering filter + comparand (#8793); and undeclared keys on the analytics surface, on object + `indexes[]`, on `Field.inlineColumns` / `Field.relatedListColumns` and on five + more `record:*` component prop maps. +- **Studio and metadata-API writes are now judged by the same rules.** If you + publish through `PUT /api/v1/meta/…` or Studio rather than the CLI, expect + writes that previously returned `200` to be refused — dashboard widget bindings + (#7529) and the five gating object rules now run at the publish door. +- **Flow callers: branch on the HTTP status, not on an inner `success` flag.** + The automation `trigger` routes, `POST /api/v1/actions/:object/:action` and + declared `type: 'flow'` endpoints all answer `404` / `409 FLOW_DISABLED` / + `422 FLOW_NO_START_NODE` / `400 FLOW_FAILED`. A caller that treated `400` as + "the run failed", or that read a declared endpoint's result out of a `200` body, + needs updating — a declared endpoint's failures are now 4xx with the platform + error envelope and no inner `data.success` to read. `outputMapping` is no longer + applied to a failure. If you call the resume route, send only `inputs`, + `variables`, `output` and `branchLabel`: any other top-level key is now a `400`. +- **Apps that throw their own `error.code` should read `declaredCode` (#9106).** + A thrown code outside `StandardErrorCode ∪ ERROR_CODE_LEDGER` no longer reaches + `error.code`; it arrives on the new optional `declaredCode` instead. One-line + fix: branch on `error.declaredCode` for app-specific spellings and on + `error.code` for platform conditions. Platform producers are unaffected. +- **Retired surfaces.** `sys_user_permission_set.delegated_from` is gone (#9730); + `element:filter` (#9220), `targetVariable` on `element:text_input` / + `element:record_picker` (#9198), `MetadataPluginConfig.additionalTypes` (#8586) + and `ApiKeySchema` (#8715) are retired; and `BATCH_PARTIAL_FAILURE`, + `BATCH_COMPLETE_FAILURE` and `TRANSACTION_FAILED` leave `StandardErrorCode`. + Console hosts: `app-shell` is no longer a component key, ``'s + `description` prop is gone in favour of `subtitle`, the structured `confirm` + object on actions is retired in favour of `confirmText`, and + `dashboardComponents` is re-keyed to the eight schema `type` keys. +- **`file-storage` → `storage` (#9683).** The canonical `CoreServiceName` slot is + now `storage`. `file-storage` keeps working as a deprecated alias for the v17 + line and retires at the next major; a custom v17 provider for this slot should + register **both** names. +- **API keys under `isolated` tenancy (#8287).** Keys minted before this release + carry no organization and are **refused** under `OS_TENANCY_POSTURE=isolated`, + where they already read nothing. They are deliberately not backfilled — re-mint + them. Keys keep working under `single` and `group`. +- **If a config read `process.env.OS_ARTIFACT_PATH` expecting the CLI to have + populated it (#8985)**, 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 rung it has always occupied; `os start` / `os dev` simply no longer + manufacture it on your behalf. +- **Automations:** a triggered run now carries the flow author's + `successMessage` / `errorMessage`, and a flow ACTION that ran and failed carries + `errorMessage` and the run `summary` in `error.details` — review anything that + parsed the previous generic text. + diff --git a/content/docs/releases/v17/17.2.mdx b/content/docs/releases/v17/17.2.mdx new file mode 100644 index 0000000000..906747babb --- /dev/null +++ b/content/docs/releases/v17/17.2.mdx @@ -0,0 +1,377 @@ +--- +title: 17.2.0 +description: "Release notes and upgrade checklist for 17.2.0 of the v17 line." +--- + +## Highlights — 17.2.0 + +- **Two write-path guardrails close the "silently dropped predicate" hole.** A + by-id `update`/`delete` whose `where` names anything besides `id` now refuses + loudly instead of binding the row unconditionally and discarding the extra + keys — a compare-and-set written as `{ where: { id, status: {...} } }` used to + land unconditionally with no diagnostic (#11009). A by-id `update` whose + `data.id` and `where.id` are both truthy scalars that **disagree** now refuses + `UPDATE_ID_MISMATCH` (HTTP 400) instead of writing the payload row and + silently dropping the losing id (#11142). Both were unconditional writes + masquerading as conditional ones, not failures — flow `update_record` / + `delete_record` nodes that named `id` plus other filter keys without + declaring `multi: true` are where this is most likely to surface. +- **Further ADR-0049 enforce-or-remove retirements.** `sys_position.permissions` + — a security-object column no producer ever wrote and no runtime path ever + read — is gone (#9885); so are `MetricSchema.filters`, the per-metric raw-SQL + filter nothing read (#10414), the `record:highlights` field `icon` (#10054), + and the `themes` carrier key plus `ThemeSchema` — `app.branding` remains the + one color-authoring surface (#10485). +- **`http_request_errors_total` is retired** (#9834). Its only emitter never + saw the REST data API, the auth mount, or any inbound surface but the + dispatcher's own route Proxy, so the series undercounted from day one. A + dashboard or alert keyed on it now reads a flat zero — that zero is the + removal, not a healthy server. +- **Analytics stops answering the wrong number on a cross-object filter.** A + filter nested inside a combinator (`$or`, `$not`, a nested `$and`) on the + ObjectQL path used to reach `engine.aggregate` unchecked, because the + cross-object envelope check only saw a top-level AND-ed leaf; both analytics + doors now refuse it the same way a top-level cross-object filter already was + (#10759), and a dataset's own definition-level `filter` gets the identical + guard (#10861). +- **Driver introspection stops guessing.** `driver-sql`'s + `introspectPrimaryKeys` / `introspectForeignKeys` / `introspectUniqueConstraints` + used to swallow a failed read and report "no keys" with no diagnostic; a + failed read now throws by default (`{ onFailure: 'partial' }` opts back into + the old behaviour) — schema-drift comparisons and federated-object codegen + were consuming that silent absence as a real answer (#11161). +- **CLI: two dead authoring surfaces are gone.** `os g agent` is retired and + now says why and points at skills — the kernel ships exactly two agents + (`ask`/`build`) per ADR-0063 §2, so scaffolding a third was already discarded + (#10359). The `@capabilities` hook-body directive comment is retired — the + build strips the `//` comment it read before any handler is a runtime + function, so it never reached a build that used it (#10917). +- **A per-item publish naming `?package=` stops matching another package's + draft.** `POST /api/v1/meta/:type/:name/publish?package=PKG_ID` now resolves + its draft's org scope package-exactly, closing a path where the scope probe + could match a different package's draft in the caller's org and the + package-exact promote then 404'd over the caller's own publishable draft + sitting env-wide. A publish that states `?package=` no longer discovers a + package-less draft of the same `(type, name)` — retry without the query + parameter for that draft. + + +## What's new in 17.2.0 + +17.2.0 was published to the `latest` tag on **2026-08-23**, three days after +17.1.0. The version-locked train moved the same **69 packages**, carrying **204 +distinct changelog entries and no major** — **19** of which mark themselves +BREAKING. (Counted across the 69 package `CHANGELOG.md` files; an entry that +lands in several packages is counted once.) The bundled Console advances one pin, +`9a3daf8d37ad → 190fbd01d061`. + +⚠️ **Read this before treating the version number as a safety guarantee.** As +with 17.1.0, several entries here landed after the 17.0.0 cut and ship as `minor` +under the lockstep launch-window convention while being explicitly breaking — +they say so in their own changelog entries. The theme is the same one 17.0.0 and +17.1.0 established, one surface further in: **a write that declared a condition +nobody evaluated stops reading as a working conditional write**, and a +declared-but-unenforced authorable key is removed rather than maintained. + +### Breaking changes & migration in 17.2.0 + +#### Two write-path guardrails close the "silently dropped predicate" hole (#11009, #11142) + +The by-id dispatch routes to `driver.update(object, id, …)` / +`driver.delete(object, id, …)`, which bind **only** the primary key — every other +`where` key was discarded with no diagnostic. A compare-and-set written as +`{ where: { id, status: { $in: [...] } }, multi: false }` therefore evaluated to +nothing and the write landed unconditionally, reading exactly like a working +conditional write (`8cc8401`). + +Per call shape: + +- A `where` naming a scalar `id` **and nothing else** is unchanged — by-id, with + or without `multi: true`. +- A `where` carrying a scalar `id` **plus other keys**, with a declared + `multi: true`, now routes to the **predicate path** (`driver.updateMany` / + `driver.deleteMany`), which compiles every `where` key. Previously this + dispatched by-id and dropped the extra keys. +- The same shape **without** `multi: true` — and any by-id call via a scalar + `data.id` beside extra `where` keys — now **throws**, naming the keys the by-id + path would have dropped. + +A second shape refuses under its own code (`2810695`): a by-id `update` whose +truthy scalar `options.where.id` names a **different** row than the truthy scalar +payload `data.id` now answers `UPDATE_ID_MISMATCH`, HTTP `400`, naming both ids — +including ids differing only in type (`42` beside `'42'`). Equal ids are +unchanged, which is the normal REST spelling. A declared `multi: true` does not +rescue the call. + +**Migration.** Each refusal is a one-line edit at the call site, and which edit is +an intent decision no codemod may make for you: + +| You wrote | Decide | +| :--- | :--- | +| `{ where: { id, …other } }` without `multi` | declare `multi: true` so the full `where` is honoured (the result becomes the matched count), or drop the extra `where` keys to keep an unconditional single-row write | +| `data.id` ≠ `where.id` | make the two ids equal (or drop `where.id`) to address the row by the payload id, or remove `id` from the payload to address it by `where.id` | + +⚠️ **Flow authors reach this through `update_record` / `delete_record` nodes** +whose `filter` names `id` plus other keys without declaring `multi: true`. Those +configs were silently unconditional before and refuse loudly now. + +#### `http_request_errors_total` is retired (#9834) + +⛔ **If you have a Grafana panel, an alert rule or a recording rule keyed on +`http_request_errors_total`, it will read a FLAT ZERO after this upgrade.** That +zero is the removal, not a healthy server, and it is the one way this change can +hurt you — nothing throws, nothing warns, the series simply stops receiving +samples (`914c413`). Its only emitter was the dispatcher's own route Proxy, so it +never saw the auth mount, the REST data API, or any other inbound surface: it +undercounted from day one. + +| Wrote | Write instead | +| :--- | :--- | +| `rate(http_request_errors_total[5m])` | `rate(http_requests_total{status=~"5.."}[5m])` — emitted by the transport, so it covers every inbound surface instead of the dispatcher's routes only | +| `sum by (route) (http_request_errors_total)` | `sum by (route) (http_requests_total{status=~"5.."})` | +| `SEMCONV.httpRequestErrorsTotal` / `RUNTIME_METRICS.httpRequestErrorsTotal` in host code | Delete the read. Both members are gone; `tsc` reports the missing property at the read site. | + +#### Four ADR-0049 enforce-or-remove retirements on the authorable surface + +Each of these was declared, projected and accepted while nothing read it. Because +the authorable surface has been strict since 17.0.0 (#4001), an authored document +that still carries one of these keys is now **refused by name at parse**, not +silently dropped. + +| Retired | Where it lived | Write instead | +| :--- | :--- | :--- | +| `sys_position.permissions` (`3ee8ddf`, #9885) | a "JSON-serialized array of permission strings" textarea on the platform position object | Delete the key. Capability reaches a position **only** through permission-set bindings (`sys_position_permission_set` rows); prose documenting intent belongs in `description`. | +| `MetricSchema.filters` (`a40dcc1`, #10414) | the per-metric raw-SQL filter | Delete the key. | +| `record:highlights` highlight-field `icon` (`c684d00`, #10054) | advertised on six surfaces, drawn by nothing | Delete the key. | +| the `themes` carrier key and `ThemeSchema` (`35ad101`, #10485) | the authoring surface nothing ever applied | `app.branding` remains the one color-authoring surface. | + +Physical columns on already-deployed databases are untouched — ADR-0045 schema +sync is additive. + +#### Analytics stops answering the wrong number on a cross-object filter + +A filter nested inside a combinator (`$or`, `$not`, a nested `$and`) on the +ObjectQL path used to reach `engine.aggregate` unchecked, because the +cross-object envelope check only saw a top-level AND-ed leaf. Both analytics doors +now refuse it the same way a top-level cross-object filter already was +(`57e4571`, #10759), and a dataset's own definition-level `filter` gets the +identical guard (`13a3dca`, #10861). + +#### Driver introspection stops guessing (#11161) + +`driver-sql`'s `introspectPrimaryKeys` / `introspectForeignKeys` / +`introspectUniqueConstraints` wrapped their whole dialect dispatch in a bare +`catch {}` and returned `[]`, so a query a live server rejected degraded to "this +table has no primary key" with no diagnostic — a wrong answer downstream code +acted on, not "we don't know". A failed read now **throws** by default; +`{ onFailure: 'partial' }` opts a caller with a self-correcting short read back +into the old behaviour (`9cc1940`). The un-hiding immediately paid: the Postgres +arm of `introspectUniqueConstraints` had been invalid SQL all along, so live +Postgres never reported a unique constraint through this method. That query is +repaired in the same change. + +#### The external-datasource federation family requires a capability + +These routes previously admitted **any authenticated caller**. This is published +SDK surface — `datasources.external.*` on `ObjectStackClient` and the CLI's +`datasource` commands reach exactly these routes — so an existing integration +presenting a valid credential that holds neither capability was served before and +is **refused now**, `403 PERMISSION_DENIED` naming the missing capability +(`9a1ed7a`, #9901; `6ce58a7`, #10255). + +| Route | SDK call | Now requires | +| :--- | :--- | :--- | +| `GET /:name/external/tables` | `datasources.external.listTables` | `manage_platform_settings` | +| `POST /:name/external/tables/:remote/draft` | `datasources.external.draft` | `manage_platform_settings` | +| `POST /:name/external/tables/:remote/import` | `datasources.external.import` | `manage_metadata` | +| `POST /:name/external/refresh-catalog` | `datasources.external.refreshCatalog` | `manage_metadata` | +| `POST /:name/external/validate` | `datasources.external.validate` | `manage_platform_settings` | + +**Migration.** Grant the calling credential's permission set the named capability. +The platform's `admin_full_access` set carries both; a purpose-built operator set +is the case to check. + +#### A per-item publish naming `?package=` stops matching another package's draft + +`POST /api/v1/meta/:type/:name/publish?package=PKG_ID` now resolves its draft's +org scope **package-exactly**, closing a path where the scope probe could match a +different package's draft in the caller's org and the package-exact promote then +`404`'d over the caller's own publishable draft (`c74aefe`). What you may newly +see: a publish that states `?package=` no longer discovers a draft of the same +`(type, name)` authored with **no** package binding — it answers `404 [no_draft]`. +**Remedy:** if the draft you mean is the package-less one, retry the publish +without the `?package=` query parameter. + +#### Two dead CLI authoring surfaces are gone + +- **`os g agent` is retired** (`15b63e8`, ADR-0063 §2, #10359). ⛔ If a script, a + Makefile or a CI step runs it, **it now exits 1** — that is the intended + outcome and the one way this change interrupts you. The kernel ships exactly + two agents, `ask` and `build`, bound by surface, and the runtime catalog filters + out every non-platform agent record: the scaffolded file parsed, validated, + published, and then never appeared anywhere. The refusal names skills as the + surface to author instead. +- **The `@capabilities` hook-body directive comment is retired** (`7940de5`, + #10917). **Nothing an author wrote has to change**: `loadConfig` runs every + config through `bundle-require` and esbuild, which strips `//` line comments + before the handler is ever a runtime function, so the directive reached the + extractor from none of the four ordinary authoring shapes. A config that still + carries the comment builds to the same artifact. Declare capabilities as data in + `body.capabilities` on the hook or action. + +#### Smaller breaking changes in 17.2.0 + +- The better-auth-native `/api/v1/auth/admin/` routes refuse an anonymous caller + with the declared ADR-0112 envelope instead of a `401` whose body was the empty + string under an `application/json` header (`4d7c564`, #10349). Statuses and + admission are unchanged; what is added is the machine-readable `code`. +- `IHttpOutbox.redeliver` and `MessagingService.redeliverHttp` change signature so + the caller's tenant is threaded rather than swept globally (`cdaa72f`, #10740). + `SqlHttpOutbox.redeliver` now rides the predicate path, so a delivery row + claimed between its read and its write is not reset and `redeliver` reports + `DELIVERY_NOT_ELIGIBLE` instead of success. +- Thirteen logger sink types now declare a **non-optional** `warn`, so a + durability report always has somewhere to land (`e222a53`, `b47ba2c`, #9754, + #10556). Compile-time only — `error` stays optional, call sites keep the + `logger?.warn?.(…)` backstop, and no runtime behaviour changes. It can break a + host that injects its own reduced sink. +- `driver-sql`'s `introspectSchema()` emits the spec introspection contract — + `primaryKey`, `dialect`, `introspectedAt` (`95437e7`, #10676, #10998). +- The `/meta` FSM state route is singular: `meta.getLegalNextStates` moves and the + plural registration is retired (`67630c4`, #10077). + +### New capabilities in 17.2.0 + +- **`lifecycle.ttl` accepts an `onlyWhen` row filter** mirroring + `retention.onlyWhen`, and the shared `onlyWhen` value union gains the platform's + canonical relative-date vocabulary (`8012960`). +- **`os g skill NAME` scaffolds an AI skill**, written as `NAME.skill.ts` so the + loader finds it (`1c3a46f`, #11025) — the replacement surface `os g agent` now + points at. +- **A per-column sortability projection** is served on + `GET /api/v1/meta/:type/:name` (`e5ea701`), so a console can disable the sort + affordance on a column the query engine will refuse rather than discovering it + at query time. +- **`sharingModel` on the solution blueprint's object schema** — the enum + `private | public_read | public_read_write | controlled_by_parent` reaches the + design stage (`7d2d112`), and `nameField` joins the strict blueprint mirror + (`ceb33a9`). +- **`theme` and `analytics_cube` are validated at the `/meta` write door** + (`2306a76`, #10194), so a Studio or API write is judged by the same rules as an + `os build`. +- **`os migrate duplicates` reports the rows blocking the three `kernel:ready` + uniqueness reconciliations** (`2866d5f`) instead of leaving a boot to fail on + them. +- **`MetadataRepository.watch()` replays from the durable log on a numeric + `since`** (`f334d66`), so a reconnecting watcher no longer silently starts from + now. +- The runtime publish gate reaches further: list-view field rules judge a + standalone `view` write, and a standalone `ViewItem` record's nested + `config.sort` / `config.searchableFields` are judged there too (`adbcbfd`, + `f1b5ad3`, `def0d3e`) — Studio and metadata-API writes are held to the rules + `os build` already applied. +- `os lint` reports an unparseable source instead of scoring it CLEAN + (`78818ec`, #10653); `os serve` refuses a relative `plugins: [...]` entry, + naming the two spellings that work (`e598b1c`). +- Time-relative sweeps are idempotent per matched window (`73d9795`, #10220); + `sys_session` gains a declared ADR-0057 lifecycle policy (`dccbcec`, #7826); the + `manager` approver is screened to the request's organization (`13f533a`, + #10153); and the `__search` companion is no longer provisioned on objects whose + only companion source is the primary key (`2570ab0`, #10290). + +### New in Console (Studio) — objectui pin `9a3daf8d37ad → 190fbd01d061` + +One pin move (`208bd22`), derived from 113 releasing changesets over 141 objectui +commits. What a console host or an author notices: + +- ⚠️ **Console hosts:** `options.actor` is removed from `MetadataClient`'s `save` + / `reset` / `publish` / `rollback`, and the `X-Actor` request header is no + longer emitted (objectui `8e00bfd28`). The six retired `@objectstack/spec/ui` + theme-schema re-exports leave `@object-ui/types/zod` with the `themes` + retirement above (objectui `920165d18`), and `ThemeComponentSchema` + (`type: 'theme'`) — a component kind no renderer implemented — is retired + (objectui `78cbdb530`), as is the register-meta key `defaultChildren` + (objectui `fa429cf6f`). +- **`stack` reads its spacing from `gap` and nothing else** — the undeclared + `spacing` key it also accepted is gone (objectui `dd194635d`). +- Authored predicates are actually consulted: `record:alert` binds its row through + `usePredicateRecordContext` so `properties.visible` is read (objectui + `8a4439081`), and a hoisted `properties.visible` stops swallowing a declared + `visibleWhen` (objectui `c86185eb5`). +- `object-grid` / `object-form` / `detail-view` resolve their data source the same + way, and a block that resolves none says so (objectui `ebce5a367`); `ReportView` + reads `dataSource.object`, the one key the contract declares (objectui + `60d452ee0`); `data-table` reads the declared `header` (objectui `e719ebdd9`). +- Create forms pre-fill the `current_user` `defaultValue` token with the acting + user (objectui `3c9fca3dc`); three more secret-field spellings stop rendering a + secret in clear text on the unregistered-widget branch (objectui `91783c47b`); + and a screen flow's resume result reaches the user on both outcomes (objectui + `c40f3b8ca`). + +The per-commit list is in `packages/console/CHANGELOG.md` under `## 17.2.0`, +which records the upstream objectui commit for every entry. + +--- + + +## Upgrade checklist + +⚠️ One checklist per release, for the release you are landing on **and** every release you cross to get there — and see [how far each list has actually been walked](/docs/releases/v17#upgrade-checklists). + +### 17.2.0 + +⚠️ **Not exercised.** No upgrade across 17.1.0 → 17.2.0 has been walked +end-to-end. Every line here is derived from the change's own **Migration** note +in [Breaking changes & migration in +17.2.0](#breaking-changes--migration-in-1720); none of it carries a measurement, +and the effort each one costs on a real deployment is unknown. Treat this list as +a reading order for that section, not as a walked path. + +- **Audit every by-id write whose `where` names `id` plus other keys (#11009, + #11142).** This is the one that used to fail silently: a compare-and-set like + `{ where: { id, status: { $in: [...] } } }` dropped every key but `id` and + landed unconditionally. Declare `multi: true` to have the full `where` honoured + (the result becomes a matched count), or drop the extra keys to keep an + unconditional single-row write. **Flow authors reach this through + `update_record` / `delete_record` nodes** whose `filter` names `id` alongside + other keys — those configs were unconditional before and refuse loudly now. +- **Make `data.id` and `where.id` agree**, or drop one of them. A by-id update + naming two different rows answers `UPDATE_ID_MISMATCH` / `400`, including ids + that differ only in type (`42` beside `'42'`). +- **⛔ Rewrite any dashboard, alert rule or recording rule keyed on + `http_request_errors_total` (#9834).** It is retired, and nothing throws or + warns: the series simply stops receiving samples, so the panel reads a **flat + zero** that looks like a healthy server. Move to + `http_requests_total{status=~"5.."}`, which the transport emits and which + therefore covers every inbound surface rather than the dispatcher's routes only. + Delete any host-code read of `SEMCONV.httpRequestErrorsTotal` / + `RUNTIME_METRICS.httpRequestErrorsTotal`; `tsc` will point at the site. +- **Delete four retired authorable keys, then re-run `os validate`**: + `sys_position.permissions`, `MetricSchema.filters`, the `record:highlights` + highlight-field `icon`, and the `themes` carrier key. Each is now refused **by + name at parse** rather than silently dropped, so a stack carrying one fails to + build. Deployed database columns are untouched. +- **Grant the federation capabilities to any credential calling + `datasources.external.*`** (or the CLI's `datasource` commands). These routes + admitted any authenticated caller before and now require + `manage_platform_settings` or `manage_metadata` per route, answering `403 + PERMISSION_DENIED` by name. `admin_full_access` already carries both; a + purpose-built operator set is the case to check. +- **Remove `os g agent` from any script, Makefile or CI step** — it is retired and + exits 1. Author skills instead. +- **Re-check analytics filters nested inside `$or` / `$not` / a nested `$and`.** A + cross-object filter in that position used to reach `aggregate` unchecked and + answer the wrong number; both analytics doors and a dataset's definition-level + `filter` now refuse it the way a top-level one already did. +- **A publish naming `?package=` no longer finds a package-less draft** — it + answers `404 [no_draft]`. If the draft you mean carries no package binding, + retry the publish without the query parameter. +- **Hosts injecting their own logger sink** must supply `warn`; it is + non-optional on thirteen sink types now. Compile-time only. +- **Callers of `IHttpOutbox.redeliver` / `MessagingService.redeliverHttp`** move to + the new signature (the caller's tenant is threaded rather than swept globally), + and a delivery row claimed between read and write now reports + `DELIVERY_NOT_ELIGIBLE` instead of success. +- **`meta.getLegalNextStates` is singular** — the plural route registration is + retired. + diff --git a/content/docs/releases/v17/17.3.mdx b/content/docs/releases/v17/17.3.mdx new file mode 100644 index 0000000000..383c9aa916 --- /dev/null +++ b/content/docs/releases/v17/17.3.mdx @@ -0,0 +1,783 @@ +--- +title: 17.3.0 +description: "Release notes and upgrade checklist for 17.3.0 of the v17 line." +--- + +## Highlights — 17.3.0 + +- **`client.projects.*` becomes `client.environments.*`, with no aliases** + (`87042b5`, ADR-0006 D2). The SDK half of one coordinated cross-repo rename: + the method namespace, the environment-scoped sub-client (`client.project(id)` + → `client.environment(id)`, `ScopedProjectClient` → `ScopedEnvironmentClient`) + and the response keys (`res.projects` → `res.environments`, `res.project` → + `res.environment`) all move together. There is deliberately no + `client.projects` getter and no `res.project ?? res.environment` hedge — + ADR-0006 D3 declined a mapping layer with reasons. The URL paths do not move; + they were already on the `environments` spelling. `os environments --format + json` payloads change with the wire. +- **Who may become a user of an environment's apps is one declaration, and its + default flips to the safe end** (`4f24e9d`). `auth.audience.posture` is + `invite_only | email_domain | open`, and an **undeclared** audience now means + `invite_only`: self-serve sign-up is refused `403 + SELF_REGISTRATION_CLOSED` unless the address holds a pending `sys_invitation` + (the first account on a fresh install is exempt). ⚠️ **A deployment that + relied on open registration changes behaviour with nothing to parse-fail on.** + The one-line fix is to declare it — `auth: { audience: { posture: 'open', + selfRegistrationPermissionSet: 'member_default' } }`. +- **A permission-store read failure fails LOUD** (`6a180e4`). `tryFind` answered + a thrown read exactly the way it answered an empty one, so an outage of the + store resolved as a well-formed context for an authenticated principal holding + no capabilities — an administrator was told they lack a capability, during an + outage of the store that holds the capability. An unreachable store now raises + `AuthzStoreUnavailableError`, carrying the existing `SERVICE_UNAVAILABLE` code + and `503`. A reachable-but-empty store, an unprovisioned `sys_*` table and a + genuine denial all keep their previous answers. +- **`driver-memory` enforces the uniqueness it always declared** (`56c093c`, + `b7f645a`). `InMemoryDriver` enforced none: a `unique: true` field and a + `unique` object-level `indexes[]` entry were declared-and-not-enforced, so a + colliding write landed and a read returned both rows. Both surfaces now refuse + with the SQL family's envelope — `UNIQUE_VIOLATION`, `409`. **Most likely to + present as "our seed data stopped loading"** on a dev or demo stack that + relied on the store accepting a duplicate; every one of those refusals is a + write the SQL family already refused. +- **`sys_record_share` is tenant-scoped, and the rows written before it need an + operator-invoked backfill** (`3f64fe6`). Every grant row on every deployment + was written with `organization_id = NULL`; the writer is repaired and + `planSysRecordShareOrganizationBackfill` / `runSysRecordShareOrganizationBackfill` + (dry run first, by default) stamp the existing rows from the record they grant + access to. +- **`POST /api/v1/automation/:name/toggle` requires `manage_metadata`** + (`266436a`). An authenticated caller without it is answered **403 + `PERMISSION_DENIED`** where it previously received `200` with the flow's + enablement changed. The execution doors (`trigger`, `resume`) and the reads are + untouched. +- **Four SDK methods stop handing you the dispatcher envelope** (`db16b94`). + `analytics.query` / `analytics.meta` / `analytics.explain` and + `automation.trigger` now resolve to the payload like every other method: + `r.data.rows` → `r.rows`. Three of the four turn every old read into a compile + error; `automation.trigger` is the exception, because `r.success` and `r.error` + compile before *and* after while their meaning moves from the envelope's flag + to the run's own. +- **Compound metadata addressing is retired** (`7986d97`). + `GET`/`PUT /api/v1/meta/:type/:section/:name` and the `.../published` sibling + answer `404 ROUTE_NOT_FOUND`; address every item through the single-segment + route with the name percent-encoded. `@objectstack/client` already sends the + new spelling, and the encoding is a no-op for every name the item-name grammar + admits. +- **The `RestServerConfig` blocks are parsed rather than ignored** (`1394768`, + `8965398`, `f60ab90`, `b3a63d3`). `config.api`, `api.projectResolution` and + `config.crud` are now parsed by name, and the ten inert keys the liveness + ledger recorded as `dead` are gone — a key that used to be accepted and + silently dropped is refused by name. +- **The largest ADR-0049 enforce-or-remove wave of the line.** Authorable + surfaces that were declared and never enforced are removed rather than + maintained: the plugin manifest's `contributes` block loses its last ten + members and `manifest:` itself goes strict (`be21955`, `bc56e18`, `dce5cd4`, + `4d0d944`), `page.components[].responsive` and the `ResponsiveConfig` + vocabulary go (`2a6122b`), the `allowRestore` / `allowPurge` object-permission + bits go (`8af88dd`), and preview mode goes with them (`0c2334f`). Because the + authorable surface has been strict since 17.0.0 (#4001), each of these is a + **parse-time refusal naming the key** rather than a silent drop. +- **Console:** four objectui pin moves — `190fbd01d061 → 9602dc820450 → + d8ec8d6d4f01 → 67dadd602a3a → 00d3f09c500c` (`df59de0`, `83be460`, `7642aac`, + `41b5a44`). + +--- + + +## What's new in 17.3.0 + +17.3.0 was published to the `latest` tag on **2026-09-04**, twelve days after +17.2.0. The version-locked train moved the same **69 packages**, carrying **862 +distinct changelog entries and no major** — **100** of which mark themselves +BREAKING. (Counted across the 69 package `CHANGELOG.md` files; an entry that +lands in several packages is counted once.) It is by a wide margin the largest +release of the v17 line. The bundled Console advances four pins, +`190fbd01d061 → 9602dc820450 → d8ec8d6d4f01 → 67dadd602a3a → 00d3f09c500c`. + +⚠️ **Read this before treating the version number as a safety guarantee.** As with +17.1.0 and 17.2.0, entries that landed after the 17.0.0 cut ship as `minor` under +the lockstep launch-window convention while being explicitly breaking. Three +things in this release change behaviour on a **running** deployment with nothing +to parse-fail on — the audience-posture default, the tenant-scoping of +`sys_record_share`, and the newly-enforced `driver-memory` uniqueness — and one +renames a published SDK namespace with **no aliases at all**. + +### Breaking changes & migration in 17.3.0 + +**This section is triaged, not exhaustive.** An entry is written up here when the +change can be reached from something an application ships or operates — its +metadata, its data, its own code calling the SDK / REST / CLI, its deployment +config, or a plugin it authors. On that rule 94 of the release's 100 self-marked +BREAKING entries are named below, by changeset hash, and six are left to the +per-package `CHANGELOG.md` files because nothing an application can author or +call reaches them: the six branded identifier schemas and `EventNameSchema` +(`45b9051`), `MetadataChangedEventPayloadSchema` — a payload nothing ever emitted +or consumed (`50d6c92`), `RestApiEndpoint.handlerStatus` with the Route Coverage +Report shapes (`53d3689`), the orphan `CLICommandContributionSchema` export +(`7a25e7d`), `SendTemplateInput.org` (`8619f95`), and `FilesystemLoader.list()` +reporting only the names its siblings can resolve (`4b4d5a3`). The four Console +pin refreshes are named once under [New in +Console](#new-in-console-studio--objectui-pins-in-1730) rather than enumerated +here. + +#### `client.projects.*` becomes `client.environments.*` (#12866, #12882, ADR-0006 D2) + +**No aliases exist.** The old namespace is gone, not deprecated — there is no +`client.projects` getter, no `res.project ?? res.environment` hedge, and none is +coming (ADR-0006 D3 declined a mapping layer with reasons; the v5.0 rename rule +「no aliases」 is the standing one). Every call site moves in one edit +(`87042b5`). This is the **SDK half** of one coordinated cross-repo rename; the +producer half is the cloud control plane, renaming the same field keys on the same +endpoints. + +**Method namespace.** Every member moves, `list` / `get` / `create` / `update` / +`delete` / `activate` / `rotateCredential` / `updateHostname` / `updateVisibility` +/ `listRevisions` / `listBranches` / `renameBranch` / `deleteBranch` / +`retryProvisioning` / `listDrivers`, and the nested `client.projects.packages.*` +with them: + +```ts +client.projects.list(…) // before +client.environments.list(…) // now +``` + +**The environment-scoped sub-client** moves in the same edit — `client.project(id)` +→ `client.environment(id)`, and the exported class `ScopedProjectClient` → +`ScopedEnvironmentClient`. An `import { ScopedProjectClient }` fails at the import +line, which is the loudest and most precise channel this change has. Nothing about +its behaviour moves. + +**Response keys.** + +| before | after | where | +| :--- | :--- | :--- | +| `res.projects` | `res.environments` | `list` (the `total` key is unchanged) | +| `res.project` | `res.environment` | `get`, `update`, `activate`, `updateHostname`, `updateVisibility`, `retryProvisioning` | + +The joined blocks on `get` (`database`, `credential`, `membership`, +`organization`) keep their names, as do every `packages.*` key, the `delete` / +`listBranches` / `renameBranch` / `deleteBranch` payloads and `listRevisions`. +The **URL paths are unchanged** — they were already on the `environments` +spelling. + +**CLI.** `os environments list | show | create | switch | bind` follow the same +rename. No flag, argument, exit code or command id changes, but `--format json` / +`--format yaml` payloads are the control-plane response verbatim, so **a script +reading `.projects` or `.project` from those payloads reads `.environments` / +`.environment` instead.** + +Two declarations that were *false* before this change are corrected rather than +carried forward: `create` never answered a `project` key at all (it has always +answered `{ environment, warnings, durationMs, hostnameAssignment? }`), and it +declares no `database` key. `os environments create --activate` silently never +activated because it read `res.project.id` through the wrong declaration; both are +fixed here. + +⛔ Deliberately **not** renamed, each needing its own decision: `setProjectId` / +`getProjectId` on the client (a cross-package protocol contract), and the REST API +config keys `enableProjectScoping` / `projectResolution`. + +#### One declared audience posture, defaulting to `invite_only` (`4f24e9d`) + +"Who may become a user of an environment's apps" is now **one** declaration +instead of an emergent property of five switches — and its default flips to the +safe end. + +- **FROM:** an undeclared audience meant open email/password self-registration + with no email verification, and self-registrants implicitly fell back to the + `member_default` permission set. +- **TO:** an undeclared audience **is** `invite_only`. Self-serve sign-up — + email/password, social-provider OAuth JIT, magic-link/OTP/phone/anonymous, and + any unclassified creation method — is refused `403 SELF_REGISTRATION_CLOSED` + unless the address holds a pending `sys_invitation`. The first account on a + fresh install is exempt (the bootstrap bypass). + +⚠️ **Nothing parse-fails.** A deployment that meant to stay open upgrades into a +closed door. **One-line fix:** + +```ts +auth: { audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' } } +``` + +The new `auth.audience` surface on `AuthConfig` declares `posture` +(`invite_only | email_domain | open`), `allowedEmailDomains` (required non-empty +for `email_domain`) and `selfRegistrationPermissionSet` (required whenever the +posture permits self-registration; `admin_full_access` is refused). Off-vocabulary +postures and inert declarations are refused at parse **and** at plugin-auth's +config entry — never coerced. `email_domain` admits only allowlisted domains +(`403 EMAIL_DOMAIN_NOT_ALLOWED`; exact case-insensitive match, subdomains not +implied). Any self-registration-permitting posture **forces** +`requireEmailVerification` on, and an explicit `false` beside it is refused at +boot. + +⛔ **Operator-driven creation is never posture-gated:** admin create-user, bulk +import, SCIM provisioning and JIT through operator-registered identity providers +keep working under every posture. + +#### A permission-store read failure fails LOUD (#13279) + +`resolveAuthzContext`'s per-read helper answered a **thrown** read exactly the way +it answered an **empty** one, so an outage of the permission store resolved as a +well-formed context for an authenticated principal holding no capabilities — and +the door answered `403 FORBIDDEN`, measured byte-identical to what a caller who +genuinely holds nothing receives. An administrator was told they lack a +capability, during an outage of the store that holds the capability (`6a180e4`). + +An **unreachable** store now raises `AuthzStoreUnavailableError`, carrying the +existing ADR-0112 code `SERVICE_UNAVAILABLE` and status `503`. No code is added to +the closed wire vocabulary. What did **not** change, and is pinned: a reachable +but genuinely empty store still resolves to zero capabilities; a genuine denial +still answers `403 FORBIDDEN`; a real engine whose `sys_*` tables were never +provisioned still resolves to zero capabilities, quietly; anonymous requests never +reach the store. + +**Migration.** This is all-transport, not just REST. A caller that treats *any* +throw from `resolveAuthzContext` as "anonymous" should re-raise +`isAuthzStoreUnavailableError(err)` instead — degrading it restores the disguise +this removes. `isMissingTableError` moved to `@objectstack/types`; +`@objectstack/metadata/errors` still re-exports it, so no consumer of that +published subpath changes. + +#### `driver-memory` enforces the uniqueness it always declared (#13197, #13239) + +`InMemoryDriver` enforced **no uniqueness at all**: `create` was a `table.push()`, +so a `unique: true` field (`56c093c`) and a `unique` object-level `indexes[]` +entry (`b7f645a`) were declared-and-not-enforced — the colliding write landed and +a read returned both rows. Both surfaces now refuse with the SQL family's +envelope: `code: 'UNIQUE_VIOLATION'`, `status: 409`. The scoping is `driver-sql`'s, +reproduced arm for arm rather than reinvented. + +⚠️ **This is the entry most likely to present as "our seed data stopped +loading".** 57 in-repo production and metadata declaration sites carry a `unique` +`indexes[]` entry — `sys_user`, `sys_session`, `sys_setting`, `sys_metadata`, +`sys_member`, `sys_team_member` and most of the identity surface among them — so +any stack served by `InMemoryDriver` newly enforces constraints the SQL family +already enforced. Every one of those refusals is a write SQL would have refused +too, and existing rows are never retroactively refused. **Migration:** a fixture +that relied on duplicates landing on a declared-unique field must stop declaring +`unique`, or stop writing the duplicate. + +⚠️ **Bare `true` means the opposite on the two surfaces, and this reproduces the +disagreement rather than smoothing it.** At **field** level `unique: true` is the +positional spelling of `'organization'`; on a **declared index** it is the +positional spelling of `'global'` — the listed columns verbatim, no organization +key part. That is deliberate, and it is staged for retirement at protocol 18. + +#### `sys_record_share` is tenant-scoped, and pre-existing rows need a backfill (#14484) + +Every `sys_record_share` row on every deployment was written with +`organization_id = NULL`: `SharingService.grant` wrote under a bare system context +and the row literal never carried the column. Reads agreed with writes, so nothing +was visibly broken; what the NULL cost was the cliff — the first tenant-facing read +of the table inherits plugin-security's Layer 0, whose strict +`organization_id = :tenant` wins, and **every existing grant silently disappears** +(not refused; simply "this person was never granted access") (`3f64fe6`). + +**Writer.** `SharingService.grant` now stamps `organization_id` on both halves of +its upsert. A rule-materialised grant carries the granting rule's organization; a +direct grant carries the shared record's organization, with the acting session's +organization as the fallback only for a record that carries none. + +**Ops — the backfill, dry run first and by default.** An operator-invoked module +scans only rows whose organization column is unset, re-reads each row's record at +repair time, and stamps the row with the record's own organization: + +- `planSysRecordShareOrganizationBackfill(engine)` reads only and returns a report + naming every row it would touch. +- `runSysRecordShareOrganizationBackfill(engine, { dryRun: false })` writes. + +Nothing runs at boot and nothing is scheduled. It is idempotent by construction. +Orphan rows — grants whose record no longer exists — are left NULL, counted and +logged, never deleted here: that population is already owned by the +`kernel:bootstrapped` orphan sweep. + +⚠️ **On a walled install, two shipped paths that can resolve no organization now +meet a loud refusal** (`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`, status 500) where +they previously wrote a NULL row: a platform-global sharing rule matching an +organization-less record, and a direct grant whose read of the shared record's +organization failed. On a `single` install with exactly one organization both +derive it. + +#### `POST /api/v1/automation/:name/toggle` requires `manage_metadata` (`266436a`) + +| | before | after | +| :--- | :--- | :--- | +| authenticated caller **with** `manage_metadata` | 200, flow toggled | 200 — unchanged | +| authenticated caller **without** it | 200, flow toggled | **403 `PERMISSION_DENIED`** | +| anonymous caller | 401 | 401 — unchanged | +| engine self-invocation (`isSystem`) | 200 | 200 — unchanged | + +Nothing else on the domain moves: `POST /:name/trigger`, the legacy +`POST /trigger/:name` and `POST /:name/runs/:runId/resume` are untouched, so +ordinary members can still run the flows built for them, and +`GET /automation/_status` still serves enablement to any authenticated caller. + +The measurement behind it: the enabled bit is not a row, so no organization wall +scopes it. A tenant org owner without the capability — refused `403` by +`PUT /meta/:type/:name` at the same session — switched a shipped flow off, and an +unrelated tenant in a **different** organization read it off. Disabling a shipped +flow is functionally equivalent to deleting it for as long as it stays off, and +`DELETE /:name` was already gated. + +**Migration.** A caller that toggles flows programmatically — +`client.automation.toggle(name, enabled)` — must present a principal holding +`manage_metadata`. + +#### Four SDK methods stop handing you the dispatcher envelope (#13079) + +`analytics.query` / `analytics.meta` / `analytics.explain` and +`automation.trigger` ended `return res.json()`, so their callers alone had to read +`.data`. All four now resolve to the payload, as every other dispatcher-served +method already did (`db16b94`). + +| method | resolves to (now) | rewrite | +| :--- | :--- | :--- | +| `client.analytics.query(q)` | `AnalyticsResult` | `r.data.rows` → `r.rows` | +| `client.analytics.meta(cube?)` | the bare cube list | `r.data[0].name` → `r[0].name` | +| `client.analytics.explain(q)` | `{ sql, params }` | `r.data.sql` → `r.sql` | +| `client.automation.trigger(name, payload)` | `AutomationResult` | `r.data.status` → `r.status` | + +For the three analytics methods every old read is a compile error (TS2339), so a +TypeScript consumer finds each site at build time. ⚠️ **`automation.trigger` is +the exception the compiler will not point at:** `r.success` and `r.error` compile +before *and* after, because `AutomationResult` declares them itself — but their +**meaning** moves, from the envelope's always-`true` flag to the run's own. A +consumer branching on either must re-read that branch by hand. A JavaScript +consumer reads `undefined` from `.data`. + +⛔ **Your `catch` blocks are unchanged.** Non-2xx answers threw before and throw +now, carrying the ADR-0112 envelope; this convergence is not "errors now throw". +`client.analytics.queryDataset(...)` is not converted — it never had an envelope. + +#### Compound metadata addressing is retired (`7986d97`) + +Stage 3 of the retirement of slash-bearing metadata item names. Stage 1 declared +the item-name grammar and refuses every slash-bearing name at the publish door +(`311433f`, #12194), so the routes removed here addressed only names that can no +longer be created. + +| stops answering | use instead | +| :--- | :--- | +| `GET /api/v1/meta/:type/:section/:name` | `GET /api/v1/meta/:type/:name` | +| `PUT /api/v1/meta/:type/:section/:name` | `PUT /api/v1/meta/:type/:name` | +| `GET /api/v1/meta/:type/:section/:name/published` | `GET /api/v1/meta/:type/:name/published` | + +A request to a retired path now answers `404 ROUTE_NOT_FOUND`. Address every item +through the single-segment route and percent-encode the name: + +``` +GET /api/v1/meta/lead/views/all_leads → GET /api/v1/meta/lead/views%2Fall_leads +``` + +**`@objectstack/client` callers need no change** — the SDK already sends the new +spelling, and the encoding is a no-op for every name the item-name grammar admits, +so the bytes on the wire are unchanged for every name that can be written today. A +pre-grammar residue row whose stored name contains a slash stays readable, +writable and deletable. + +#### The `RestServerConfig` blocks are parsed, not ignored + +`RestServer` now parses `config.api` (`1394768`), `api.projectResolution` +(`8965398`) and `config.crud` (`f60ab90`) by name, and the ten inert keys the +liveness ledger recorded as `dead` are removed (`b3a63d3`, #14691). A key that +used to be accepted and silently dropped — leaving the server on its defaults — is +now refused by name. + +#### Author-time refusals that can fail a stack which built clean on 17.2.0 + +Re-run `os validate` / `os build` after upgrading. Each of these is a parse-time +refusal naming the key, not a silent drop: + +- **Plugin manifests go strict.** `manifest:` refuses unknown keys, with its + nested `contributes` / `kinds[]` / `engine` / `engines` blocks (`4d0d944`), and + the `contributes` block loses its last ten dead members — `events`, `menus`, + `themes`, `translations`, `actions`, `drivers`, `fieldTypes`, `functions`, + `commands` (`be21955`, #10724) and `routes` (`bc56e18`, #10726) — as do the + three dead top-level containers `capabilities`, `configuration`, `extensions` + (`dce5cd4`, #11332). +- **`page.components[].responsive` and the `ResponsiveConfig` layout vocabulary + are retired** (`2a6122b`, #11027). +- **The `allowRestore` / `allowPurge` object-permission bits are retired** — + declared gates on operations that do not exist (`8af88dd`, #12497). +- **`lookup` / `master_detail` fields require a non-empty `reference`** + (`0fb8760`, #13632). +- **An `autonumber` field is `unique: 'organization'` by default**; explicit + `unique: false` opts out (`f8e8f03`, #13894). Read this beside the + `driver-memory` enforcement above — the two compose. +- **`null` is refused in comparand positions**: `$in` / `$nin` members and + `$between` bounds (`e398863`), and the ordering comparands `$gt` / `$gte` / + `$lt` / `$lte` (`d16df74`). +- **`address` and `location` values refuse undeclared keys** (`d62f990`, #13802). +- **An authored `radio` with `multiple: true` is refused at the schema layer** + (`348860c`, #11437). +- **Calendar in `appearance.allowedVisualizations` requires + `calendar.startDateField`** on list views (`96e25a8`, #13817). +- **`defineStack` refuses two actions resolving to one scope-qualified runtime + key**, and `composeStacks` refuses two input stacks that do (`279431e`, + `35dffea`); `defineStack` also refuses a stack declaring an auto-launched flow + while `requires` omits `'triggers'` (`948dd6b`, #14153). +- **A `type: 'script'` action may not declare both post-success navigation + channels** — `onSuccess` beside `opensInNewTab: true` is refused, and + `newTabUrl` without `opensInNewTab` is refused (`387e231`, `2d4fa75`). Pick one + destination: keep `onSuccess`, or keep `opensInNewTab` plus the handler's + returned `redirectUrl`. +- **`ComponentPropsMap` convergences:** `element:number`'s `filter` onto the + `ViewFilterRule` array form (`79b6a22`), `object-grid`'s `data` onto + `ViewDataSchema` (`8f79379`), `object-grid`'s legacy `defaultSort` fallback + retired (`e6ca40e`), the per-option `default` key narrowed out of the form-view + options vocabulary (`c459da6`), and `user:profile` refused by name as not + author-placeable (`97a2263`). +- **The `sys_scim_provider` platform object is retired** (`4d25d22`, #11757), and + with it the `AUTH_SCIM_PROVIDER_SCHEMA` / `AUTH_SSO_PROVIDER_SCHEMA` public + exports (`911da5f`, `89448a5`). +- **Further ADR-0049 retirements of authorable surfaces**, each a refusal naming + the key with "delete the key" as the whole migration: the `element:form` + element at element grain (`7345308`, #9249), the import-mapping `lookup` + transform's steering params (`15d58db`, #10329), the authorable + `AdvancedPluginLifecycleConfig` surface (`40a93b5`, #11825), + `HotReloadConfig.distributedConfig` and `watchPatterns` together with the two + `stateStrategy` values that were never implemented (`4635f3e`, #12340; + `ee3595c`, #12428), the inert `PluginMetadata` surfaces `configSchema` and + `hotReloadable` (`49f0dcf`, #11982, #12587), the three `PluginHealthCheck` + restart keys the monitor never performed (`b72db01`, #12032), the + component-translation `submitLabel` copy key (`d173125`, #10926), and the paper + metadata-customization protocol with its full coupling set (`9e0ba21`, #13135). + +#### Runtime and driver behaviour changes worth checking + +- **A `multi: true` update whose per-row `beforeUpdate` hooks write divergent key + sets is refused** (`dee4dd4`, #14099), and **an undeclared field a `before*` hook + writes is refused identically on every driver** (`b003cf2`). +- **A third `UPDATE_ID_MISMATCH` shape refuses** — a bound truthy scalar payload + `data.id` beside a *declared but non-scalar* `options.where.id` (`{ $in: [...] }`, + an array, `null`) (`5d16379`, #11230). This is the shape 17.2.0's #11142 + deliberately left standing. +- **`update()` on a missing id answers `null` on MongoDB and on Turso's remote + face** (`ca3fd4b`), and `driver-memory`'s `update()` / `upsert()` publish their + honest types (`93940d4`). +- **`redshift` / `cockroachdb` DDL is refused by name** and `pgnative` joins the + Postgres family (`dfebfc8`, #11991); the dangling `postgres` and `nats` values + leave `ClusterDriverSchema` (`c85a265`, #13393). +- **`sys_oauth_resource.identifier` narrows 1024 → 255**, and the referring column + with it (`d79c602`, #12313). +- **Walled postures elevate only the env-declared platform owner, never the first + registrant** (`9735662`, #11184), and the metadata HMR door is gated on an + explicit development posture (`f4e7ae5`, #12140). +- **`publicSharing.eligibility` and `publicSharing.enabled` are held at + redemption, not only at mint** (`fc9ba76`, `13bf05d`, `20293d6`) — a share link + minted while sharing was enabled stops redeeming once it is disabled. +- **`escalation.enabled` defaults to `true` and the SLA sweep finally reads it** + (`277948f`, #12278) — an approval process that declared escalation and saw none + now escalates. +- **`controlled_by_parent` composes across a chain**: a child whose master is + itself derived is no longer readable and writable org-wide (`6171331`, #11082). +- **`ExecutionStepLog.iteration` is single-valued** — the enclosing loop iteration + — and the parallel branch index moves to a new optional `branch` key (`8ab926b`, + #14414). +- **`os serve` defaults `NODE_ENV` to `production` when unset**, exactly as + `os start` already did (`918988a`, #11113); `os validate --json --strict` exits + 1 on the configs `--strict` already exits 1 for (`ab23c67`, #11174); and the + multi-node cluster gate fails **closed** when unregistered, mounted on every + boot route (`4d672c4`, #13537). + +#### Smaller breaking changes in 17.3.0 + +- **Four SDK response contracts are declared where they were unbound or wrong.** + The 17 previously-unbound client-SDK methods get their published response + contracts, and the false `PackageRollbackResponseSchema` is retired + (`dc75ba8`, #12038); `meta.deleteItem` declares the response the reset door + actually sends (`426ad58`, #13023); `SaveReportInput`'s requirements are stated + at the `reports.save` door (`3519f8d`, #11926). A TypeScript caller reading a + member the route never sent now sees it at build time. +- **A published package must declare an `exports` map**, and that is now a gate + (`6a571d3`, #12879). +- **`Plugin.type` is the closed set the spec declares** — a `PluginType` derived + from `CORE_PLUGIN_TYPES` (`d8024f0`, #13925), so a plugin declaring an + off-vocabulary type is refused rather than silently unrouted. +- **`getUiView`'s list branch honours `hidden` on the priority pass**, not just + the fill pass (`2a75270`, #13259) — a field marked hidden stops appearing in a + served list view's leading columns. +- **A quoted-empty `If-Match` entity-tag is refused** instead of silently + disabling optimistic concurrency (`47389b3`, #13576). +- **The flat-input proxy refuses a symbol key** at `set` / `defineProperty` + instead of silently persisting it (`c34f693`, #12603). +- **A deleted record's pending approvals auto-cancel** instead of stranding in the + inbox (`dda969c`, #13568), and a contained per-iteration flow failure becomes + visible in the run contract — run-level `failed`, loop iteration through `try` / + `catch`, row identity on `$error` (`18d816a`, #13681). +- **Wizard view v1** tightens `FormViewSchema` `type: 'wizard'` into a + declaration-and-refusal shape (`4bc18e5`, #13704); `cloud-connection:panel`, + `marketplace:installed-list` (`772d5de`) and `mcp:connect-agent` (`ce80ec2`) + are declared in `ComponentPropsMap`, so undeclared keys on those three widgets + are refused; and `KnowledgeRefreshPolicy.cron` is typed with the shared cron + dialect (`778c59f`, #14825). +- **Deleting a datasource evicts its driver from the data-engine registry**, so + `/api/v1/ready` recovers without a process restart (`ba64877`); a nested + datasource-config credential position is treated identically to the top-level + key it mirrors (`51ecb2f`, #13405). +- **Driver-level storage corrections that change stored or returned bytes:** the + SQLite `Field.json` codec becomes injective, one encoding across all three + dialects (`4045b95`, #12380), and MySQL stamps `updated_at` at the audit + column's own precision so an updated row stops reading as modified *before* it + was created (`64505a5`, #11224). +- **Interfaces a custom driver or plugin implements move:** `IDataDriver.update()` + declares its not-found arm (`93940d4`, #13878), `DatasourceDriverHandle.introspectSchema` + declares the spec introspection contract (`3d79144`), + `StrategyContext.executeAggregate`'s `aggregations[].method` narrows from + `string` to `AggregationFunction` (`d028b37`, #12776), `INotificationOutbox.ack()` + takes back the claimed record instead of a bare row id (`d9cf78e`), and + `sweepOrphanedRowsByRecordExistence`'s published parameter is tightened + (`3194c91`). + +### New capabilities in 17.3.0 + +- **A project of N packages compiles into one `packages[]` artifact**, with the + assembled package body declared (`7085f90`, #14439; ADR-0130). `composeStacks` + gains `manifest: 'preserve'` so N package identities survive composition + (`2e3e8c7`), and `EnvironmentArtifactSchema` declares `grantedPermissions` — the + install-time granted permission set per plugin (`e58ea8b`). +- **A row action gets the declarative single-record field write** — + `operation: 'update'` plus `patch`, with no handler to write (`effae80`, + #14092). +- **A layout section can reference a declared field group** instead of copying its + members (`39404f3`, #13855), and `fieldGroups[].visibleWhen` returns — this time + with its enforcement (`53dc739`, #12715). +- **An already-published page can be mounted on an object view** — a `page` member + on the `view` type enum (`d23dc08`, #13216) — and the command palette indexes + published pages through a page hit kind on `GET /api/v1/search` (`e764507`). +- **A sharing rule can share each matched record with the user or users named by a + field on that record** — `ShareRecipientType` gains `field` (`0f94cc7`). +- **`sys_user.locale` is a first-class column** carrying the user's own + notification language (`1401ae7`, #13881), a user may set their own + (`2fd3f1c`, #14787), and auth mail follows the caller's `Accept-Language` with + the deployment default second (`4bb412b`, #14319). +- **A rank-and-file member may edit their own `sys_user` row** (`ebb0822`, + #14959), and the verified platform owner bypasses the Layer 0 org wall + (`db39dfc`, #12974). +- **An org-scoped presentation-authoring capability, `manage_org_presentation`** + (`15eb2c9`, #12702). +- **The authorization-cache invalidation substrate** — an engine-seam write epoch + and the `authz.invalidated` channel (`4bd6faa`) — plus a cross-request + authorization grants cache (`86cbe37`, #11971) and a cached, synchronously + invalidated `sys_setting` localization read (`a8c00e2`, #11966). +- **Runtime metadata mutations and datasource record writes fan out to peer + replicas** (`1403d94`, `ef8a4b9`), so a runtime-authored object no longer answers + from one node only and a deleted datasource stops draining on the others. +- **`os lint` refuses a hook body that silently stopped being metadata** + (`ada3834`, #13651) and refuses a visibility predicate calling a CEL function the + environment does not register (`038f333`, #13594); list-view field references + (`aca23ab`) and an interface page's whitelisted visualizations (`e38da2b`) are + resolved at validate/build. +- **`--format json` failure envelopes carry the ADR-0112 `code` and `httpStatus`** + (`098a08f`, #13347), and `os validate --json` / `os build --json` carry the + computed advisory lists and `conversions` on **every** failure exit, not the + success payload alone (`33e81a5`, `d114d5e`, `79cf692`). `os lint` surfaces + ADR-0087 conversion notices (`9fd45a9`, #12297). +- **`os migrate plan` reports the platform-namespaced tables no declaration + accounts for** (`3f7f8f5`, #13204), and `os migrate apply` refuses **before** + writing any DDL when the host config exists but could not be loaded (`b4f2cda`, + #13118). +- **`meta.saveItem` can send the `If-Match` OCC header it already told callers to + send** (`6274a1a`, #11713), and `meta.deleteItem` — with `os meta delete` — can + pin a reset and discard only the pending draft (`cf71d73`, `2331b1e`). +- **`AutomationResult.status` names the terminally-failed-but-repairable run, + `'stranded'`** (`bd4aa4e`, #14384), `AutomationContext.recordLoadDenied` gives + the flow the caller-scope record-load signal (`63cd487`, #14244), and + `FLOW_INPUT_SCHEMA_INVALID` becomes a registered never-dispatched exit + (`f90e820`). +- **`DataEvent` and `BulkDataEvent` name the organization** the record or the + affected records belong to (`2aa8456`, `97bcd99`), so a tenant-scoped consumer + can tell whose event it is. +- **`Field.valueDomain`** — one closed standard-domain vocabulary shared by + settings specifiers and object fields (`1d7e76a`) — and `editMode?: 'modal' | + 'page'` on the object document (`f11fc61`, #11408). +- **The SQL connection pool is sized from `OS_DATABASE_POOL_MAX`** (`c4e8bbc`); + SQLite-family JSON columns declare `TEXT` while server dialects keep native JSON + (`9f4a6d5`, #12738); and `driver-mongodb` indexes `lookup` joins off the + canonical `reference` key (`eaba72e`, #13222). +- **`@better-auth/scim` moves from `1.7.0-rc.1` to stable `1.7.1`** — the + whole-model SCIM migration (`366f895`, epic #11632). + +### New in Console (Studio) — objectui pins in 17.3.0 + +Four pin moves (`df59de0`, `83be460`, `7642aac`, `41b5a44`) carrying the console +half of this release. The per-commit lists are in `packages/console/CHANGELOG.md` +under `## 17.3.0`, which records the upstream objectui commit for every entry — +including the entries objectui shipped with no changeset of their own, listed by +subject there rather than counted. + +⚠️ **Console hosts and authors:** several published `@object-ui/types` surfaces +are retired in this range — `MobileComponentConfig` (objectui `90665e07a`), +`DetailViewSection.hideEmpty` (objectui `1f31d3af6`), `ObjectTrigger` and +`ObjectRelationship` (objectui `41df89320`), and the `ActionCondition` +`{ expression, then, else }` branch shape on `ActionSchema.condition` (objectui +`6a9158602`). Relationship-target readers resolve a lookup's target from +`reference` alone, dropping the `reference_to` fallback arm (objectui +`045d20ba8`). + +--- + + +## Upgrade checklist + +⚠️ One checklist per release, for the release you are landing on **and** every release you cross to get there — and see [how far each list has actually been walked](/docs/releases/v17#upgrade-checklists). + +### 17.3.0 + +This is the largest release of the v17 line — 862 changelog entries, 100 of them +self-marked breaking — and the only one on this page whose checklist was written +from a **measured upgrade**. Lines marked **measured** were walked on a real +application moving 17.2.0 → 17.3.0 using published artifacts only; lines marked +**not exercised** are derived from a change's own **Migration** note in [Breaking +changes & migration in 17.3.0](#breaking-changes--migration-in-1730) and were not +reached by that run. + +**Getting onto the release** + +- **Move all twelve-or-so `@objectstack/*` pins as one set, regenerate the + lockfile, and leave `specVersion` / `engines.protocol` alone** — this is a move + inside one major, so the protocol declarations do not change. *Measured:* this + step is not a tag swap for an npm-consuming app, and until now no page described + it; the full three-step procedure is now [Moving the dependency + pins](/docs/upgrading#moving-the-dependency-pins). +- **Expect `os migrate meta --from 17` to report `Nothing to migrate`, and do not + read that as completion.** *Measured:* it exited 0 with exactly that line while + **nine** of the items on this page were live and unaddressed on that stack. Its + scope is the protocol chain; everything else on this list is outside it. (It + also prints `runtime 17.0.0` on a 17.3.0 install.) + +**Metadata and build — run `os validate` before you ship** + +- **The author-time rule count moves 41 → 42, and the new rule can refuse a stack + that built clean on 17.2.0 (#14747).** *Measured:* + `security-controlled-by-parent-ambiguous-relation` refuses an object that + declares `sharingModel: 'controlled_by_parent'` when two or more of its fields + tie for the master relation — because the resolver picks the master with + `Array.prototype.find`, which makes the security boundary depend on **field + declaration order**, silently. The remedy the diagnostic names first is to + promote the intended master into the required `master_detail` tier so only one + candidate remains; demoting the other candidate works equally. The CLI + diagnostic names the rule, the ADR, both candidates and which one currently + wins — read it rather than guessing. +- **Re-run `os validate` for the rest of the author-time refusals**, which are + parse-time and name the key: strict plugin manifests, retired + `page.components[].responsive`, `allowRestore` / `allowPurge`, `lookup` / + `master_detail` requiring a non-empty `reference`, `null` refused in `$in` / + `$nin` / `$between` / ordering comparands, undeclared keys on `address` and + `location` values, `radio` with `multiple: true`, and the ADR-0049 retirements + whose whole migration is "delete the key". Full list under [Author-time + refusals that can fail a stack which built clean on + 17.2.0](#breaking-changes--migration-in-1730). *Not exercised* beyond the rule + above. +- **Re-run your i18n gate — the lint family widened onto surfaces it did not read + before.** *Measured:* zero findings to **429** on one app, across dataset + labels / descriptions / dimensions / measures, object validation messages, view + bulk actions and page component labels. `os lint` itself still exits 0 (these + are warnings); a deployment enforcing its own zero-tolerance gate is what turns + red. ⚠️ **This is a one-way door for the translation files:** 17.3.0 both + demands these keys and accepts them, while 17.2.0 refuses them + (`Unrecognized key(s) on this locale of the translation bundle: 'datasets'`, and + likewise for `_validations` and `bulkActions`). Translations authored to clear + this gate cannot ride back to 17.2.0. + +**Data and database** + +- **⛔ On an existing database, deduplicate your autonumber columns before the new + unique indexes can build.** *Measured.* An `autonumber` field that omits + `unique` now parses to `unique: 'organization'` (`f8e8f03`, #13894) and the + drivers enforce it (#13197, #13239), so a table already holding duplicate + auto-numbers **cannot take the index**. The driver neither fails the boot nor + skips silently: it logs on the `error` channel, and the drift pass names the + conflicting key groups with row counts. Run `os migrate duplicates`, + deduplicate, then re-run `os migrate plan` — **until you do, that constraint is + not enforced**, so a boot that came up cleanly is not evidence the index exists. + A database created fresh took all ten indexes with zero errors. +- **Back-fill `sys_record_share` (#14484).** The object is tenant-scoped now, so + pre-existing grant rows need attention. *Not exercised.* + +**Deployment and configuration** + +- **⛔ A walled deployment (`OS_TENANCY_POSTURE=group` or `isolated`) must declare + `OS_PLATFORM_OWNER_EMAIL`, or it will not boot.** *Measured:* `AuthPlugin.init()` + refuses to start without it. Walled postures now elevate only the env-declared + platform owner and never the first registrant (`9735662`, #11184), and the + bootstrap no longer mints the org-less grant row at all — `hasPlatformAdminGrant` + is read off the resolver's posture verdict rather than recomputed from grant + rows. If a test or a fixture leaned on implicit first-user promotion, give that + actor an authored grant instead. +- **Decide your audience posture explicitly (`4f24e9d`).** It collapses to + `invite_only | email_domain | open` and **defaults to `invite_only`**, so a + deployment that relied on open self-registration must declare it. *Not + exercised.* +- **A permission-store read failure now fails LOUD (#13279).** A deployment that + had been quietly running degraded starts erroring. *Not exercised.* +- **`POST /api/v1/automation/:name/toggle` requires `manage_metadata` + (`266436a`).** *Not exercised.* + +**Application code, hooks and flows** + +- **Search your hooks for `delete ctx.input.` — it was a no-op and now + actually deletes (#12277).** *Measured.* This shipped graded `minor` + deliberately, and its own changelog says it: any hook already containing that + statement has been inert until now and starts taking effect on upgrade. Both + mechanisms closed together — the in-process flat-record Proxy grew a + `deleteProperty` trap, and the sandbox path diffs deletions against the entry + snapshot instead of using `Object.assign`, which cannot represent a removal. If + a hook needs a column to arrive `null` rather than absent, it must **assign**, + not delete. +- **An undeclared key written by a hook is now refused by every driver + (`b003cf2`).** *Measured.* Through 17.2.0 `driver-memory` accepted and stored a + key the object never declared — outside field-level security by construction, + since an undeclared field can carry no `fieldPermissions` entry — while the + SQLite drivers refused it as a raw error carrying neither an ADR-0112 `code` nor + a `status`. All three now answer `INVALID_FIELD` / `400`. Real behaviour change + for any app whose hook quietly wrote an undeclared key on `driver-memory`. +- **A flow value expression naming an unknown function now refuses the run + (#11060).** *Measured.* The evaluator knows its own closed vocabulary + (`round` / `floor` / `ceil` / `abs` / `min` / `max`, plus `NOW()` / `TODAY()`), + and anything else is a loud refusal. This deletes a genuinely dangerous silent + branch: `TOMORROW()` used to be rewritten to null and the field written + `undefined`, and the unwrapped form `LOWER({x})` interpolated to the **literal + text** `LOWER(ACME Corp)` and landed in the database looking as though it had + worked. +- **Tests that pin the record-level `FORBIDDEN` message text will fail.** + *Measured.* The message is user-facing prose now + (`insufficient privileges to update crm_contract` became `You do not have access + to change or delete this record. …`). `code` and `status` are unchanged, which is + the switchable contract — pin those tightly and the message loosely. +- **Hook bodies can now see they are on a per-row dispatch (#11552).** *Measured.* + `ctx.dispatch` (`{ mode, index }`) and a frozen `ctx.input.options` + (`{ multi, where }`) projection cross the sandbox boundary; `ctx.input.id` still + does not, and `scope` does not cross with `dispatch`. This is a capability + rather than a break — but a test pinning their **absence** goes red, and that + red is the blocker lifting. +- **Deep imports into `@objectstack/cli` subpaths stop resolving + ([#15325](https://github.com/objectstack-ai/objectstack/issues/15325)).** + *Measured.* 17.2.0 published no `exports` field; 17.3.0 adds one admitting only + `.` and `./console`, so an import of anything else fails `tsc` with TS2307 and + Node with `ERR_PACKAGE_PATH_NOT_EXPORTED`. `@objectstack/cli/package.json` is + sealed too. If you depend on an unratified subpath, resolve the package root + (which the map does admit) and load the file relative to it while the subpath is + considered for ratification. +- **Move `client.projects.*` to `client.environments.*` (#12866, #12882, + ADR-0006 D2).** *Not exercised* — the measured app calls no control plane. **No + aliases exist and none are coming**; response keys move with the methods, and + `os environments --format json` changes shape. The full method and key table is + in [the detail section](#breaking-changes--migration-in-1730) — use it rather + than paraphrasing it. +- **Four SDK methods stop handing you the dispatcher envelope (#13079)**, and a + third `UPDATE_ID_MISMATCH` shape refuses — a truthy scalar `data.id` beside a + declared but non-scalar `options.where.id` (`5d16379`, #11230), the shape + 17.2.0 deliberately left standing. *Not exercised.* + +**Measured and found clean** + +These were checked on the same run and needed no action. They are recorded +because "we checked X and it was fine" is a checklist input too: seed data loaded +unchanged on a fresh database (the predicted "our seed data stopped loading" from +newly-enforced uniqueness did **not** materialise); a fresh boot produced zero +`ERROR` lines with all ten unique indexes present; `os migrate meta` was correct +as far as its scope goes; and the growth in non-gating `os validate` advisories +(54 → 95) was pre-existing shapes newly reported, not new defects. + diff --git a/content/docs/releases/v17/17.4.mdx b/content/docs/releases/v17/17.4.mdx new file mode 100644 index 0000000000..ec516a821c --- /dev/null +++ b/content/docs/releases/v17/17.4.mdx @@ -0,0 +1,429 @@ +--- +title: 17.4.0 +description: "Release notes for 17.4.0 of the v17 line, published 2026-09-09. ⚠️ MACHINE DRAFT — mechanically derived from the changesets, not yet curated." +--- + + +⚠️ **This page is a machine draft, not curated release notes.** It was generated by +de-duplicating the **396 changesets** that make up 17.4.0 across all 61 package +`CHANGELOG.md` files (a changeset that bumps six packages appears once here, not six +times), and taking each one's own headline verbatim. + +⛔ **What it is not.** No editorial judgement has been applied: nothing has been dropped for +being internal, nothing has been merged into a themed narrative, nothing has been ranked, and +the Highlights section every other release page carries is deliberately absent rather than +guessed. Deciding what is *user-facing* is a judgement read out of these entries, and it is +the maintainer's at release time. + +**How to use it.** Prune. Entries that describe an internal refactor, a gate, a test or a +docs fix belong in the changelogs and not on a release page. What survives the prune, grouped +and rewritten, is the release section. + + +**Published 2026-09-09**, taking over from [17.3.0](/docs/releases/v17/17.3) as the current +version of the v17 line. 396 changesets across 61 packages: 35 carrying a breaking +marker, 140 other `minor` entries, 221 `patch` entries. + +## Breaking changes & migration in 17.4.0 + +⚠️ **Mechanically detected**, by a `!` in the entry's own conventional-commit prefix or the +literal `BREAKING` in its opening paragraph. ⛔ Neither the presence nor the absence of that +marker was verified against the diff — an unmarked breaking change would not appear here. + +- `be75493` — **BREAKING** `os lint --generator` now refuses to run without `--eval`, instead of accepting the flag and ignoring it. + `cli` +- `c8e5ac6` — retire `os create example` — it was a weaker `os init` plus a README, and the refusal now names `os init` (#16483, #15531) + `cli` +- `cee3961` — **BREAKING** `os create ` now refuses a project name that npm refuses, and refuses it before it writes anything. + `cli` +- `e9fcd6b` — a duration-shaped `z.number()` key carries its unit in the key name — `hook.timeout` / `job.timeout` / `DriverOptions.timeout` → `timeoutMs`, `MetadataManagerConfig.cache.databaseLoader.ttl` → `ttlMs` (the outer `cache.ttl` leaves outright under #15624 — nothing read it), tenant `idleTimeout` / `sessionTimeout` → `*Seconds`; new gate `check:duration-unit-keys` (#14478, #14519) + `cli`, `core`, `driver-memory`, `driver-turso`, `metadata`, `objectql`… +- `ffe058a` — **BREAKING** `os lint --eval --generator ""` now refuses instead of quietly running the offline eval, matching the rule the same flag already follows without `--eval`. + `cli` +- `7092d63` — the `organizations.*` family declares the wire shapes better-auth actually sends — nineteen published `Promise< any >` returns narrowed, twenty ledger entries closed (#14314) + `client` +- `7beaaa3` — `oauth.applications.delete` resolves on the zero-byte 200 its route answers, instead of rejecting on every successful delete (#15451) + `client` +- `b1b978c` — the `auth.*` family declares the wire shapes better-auth actually sends — thirteen published `Promise< any >` returns narrowed (#14313) + `client` +- `e944fdb` — the `oauth.*` family declares the wire shapes better-auth actually sends — four published `Promise< any >` returns narrowed (#14312) + `client` +- `10d05bb` — an array comparand is refused instead of compared by reference, and a `Date` comparand is compared by time value + `driver-memory`, `spec` +- `78bc4ad` — `findWithWindowFunctions()` presents its rows like every other read door — a declared boolean answers `true`, not `1` (#16609) + `driver-sql` +- `0145680` — `timeout` beside an UPPERCASE `WSS://` / `WS://` url in forced remote mode is refused at construction, closing the last corner of the same gap (ADR-0049 enforce-or-remove) + `driver-turso` +- `5071310` — `timeout` beside a pre-configured `client` in remote mode is refused at construction instead of being accepted and never delivered (ADR-0049 enforce-or-remove) + `driver-turso` +- `bc0ac1d` — `timeout` beside a `wss://` / `ws://` url is refused at construction instead of being accepted and never delivered (ADR-0049 enforce-or-remove) + `driver-turso` +- `ed5d557` — `timeout` bounds remote operations; `localPath` and `wasm` leave the published config schema (#16024, ADR-0049 enforce-or-remove) + `driver-turso`, `spec` +- `b398ad2` — **BREAKING (behaviour):** a static `readonly` field is now stripped from a **non-system caller's INSERT payload inside `engine.insert`**, exactly as it already was on `engine.update`. A non-system create that used to write a read-only column now has that column dropped, reported through `onFieldsDropped` / `droppedFields`, logged at `warn`, and refused outright under `strictReadonlyWrites`. Seeding a read-only column at create time is a **system** act — use `context.isSystem`, a flow's `runAs: 'system'`, a system hook or a seed. + `lint`, `metadata-protocol`, `objectql`, `service-automation`, `service-settings`, `spec` +- `e0af1a8` — `` / `` are retired from the react-tier component contract — `data={{ provider: 'object', object }}` / `type` are the only spellings (#14791) + `lint`, `spec` +- `7629f4d` — retire the three inert outer keys of `MetadataManagerConfig.cache` — `enabled`, `ttlSeconds` (formerly `ttl`) and `maxSize` — read by nothing; `cache.databaseLoader` is the only live half (#15624, ADR-0049) + `metadata`, `spec` +- `65846bc` — a batch ROW reports a unique-constraint refusal as `UNIQUE_VIOLATION` — the same wire spelling as the whole-request failure on the same route (#14723) + `metadata-protocol`, `objectql`, `rest` +- `8e0b297` — `positions[]` on the session payload is the SECURITY axis, not the better-auth […] scalar (#15136) *(one word elided from the changeset's own headline: ADR-0090 D3 reserved word)* + `plugin-auth`, `spec` +- `afa3a26` — a non-member schedule `timezone` no longer discards the cron expression, and a schedule already holding one stops instead of firing on a cadence nobody asked for (#16291) + `plugin-reports` +- `233222e` — `timeDimensions[].dateRange`'s string arm closes to the date-range preset vocabulary; any other string is refused with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16041) + `runtime`, `spec` +- `21aabbc` — `FlowSchema` refuses a region node whose id is already declared elsewhere in the flow — one node-id space across the top-level `nodes[]` and every region body (#16134) + `spec` +- `414c1fc` — `ComponentPropsMap['element:record_picker'].filter` converges onto the `ViewFilterRule` array form — the last record-form `filter` in the map (#14406, objectui#6206 Option B) + `spec` +- `52804cd` — `FlowSchema` refuses a flow whose `edges[]` declares the same id twice (#14964) + `spec` +- `53cf263` — `FlowSchema` refuses a flow whose top-level `nodes[]` declares the same id twice (#15713) + `spec` +- `64bd6a3` — `composeStacks` `objectConflict: 'merge'` refuses object pairs whose object-level collections cannot be merged (#14848) + `spec` +- `6f1ce7d` — a text operator over a field whose DECLARED type can never store a string is refused at the engine's field-aware door — the contract rows (#15661) + `spec` +- `8f404a5` — `plugins` / `devPlugins` are artifact envelope keys — excluded from the assembled package body and refused inside `packages[]` (#15219) + `spec` +- `9c270bb` — `GanttConfigSchema` / `TreeConfigSchema` refuse undeclared keys — both `.passthrough()` windows are closed and the ten gantt members plugin-gantt read through the window are declared (#15469) + `spec` +- `c463d03` — retire the incident-response, training and change-management families whole — nineteen defs and every name they exported — and the `ESignatureConfig` deadline pair (#15513, #14477, ADR-0049) + `spec` +- `ef3a138` — an evaluated expression slot requires a non-blank `source` — `EvaluatedExpressionSchema`, composed by the `assignment` value envelope (#15430) + `spec` +- `f81afe3` — a typed expression slot fixes its dialect on the envelope arm too, and refuses a blank string (#15028, #15035) + `spec` +- `fb77aa5` — a `tree` field's `reference`, when present, must name the declaring object — any other target is refused at parse (#14892) + `spec` +- `4f85e4d` — the record handed to a record-change flow no longer aliases the write's payload (#14744) + `trigger-record-change` + +## New capabilities in 17.4.0 + +Every remaining `minor`-bumping changeset, verbatim. + +- `000fd05` — `os generate migration` now emits the character column `driver-sql` actually creates, in both the TypeScript and the SQL format. + `cli` +- `0cde37d` — `objectstack lint` now judges hooks authored as inline `handler` functions with the same write-set rules it already applied to explicit `body` hooks. + `cli`, `lint` +- `25011b0` — point `@objectstack/cli/console` at a public barrel with a name-and-shape pin + `cli` +- `2da2901` — `os lint --strict` makes warning-severity findings fail the run, so an app can rely on the platform's warning-level rules as its gate instead of re-implementing them locally (#15935) + `cli` +- `4a1a3b0` — `os lint` no longer crashes on a localized label. + `cli` +- `6b7d709` — `objectstack validate` now lowers hooks authored as inline `handler` functions to a metadata body before it parses, so the hook write-set rules judge them there exactly as `objectstack build` and `objectstack lint` already do. + `cli`, `lint` +- `95d5cbb` — Ratify `./hook-body` as a public subpath export — `extractHookBody`, `HookBodyExtractionError`, `HookBodyRefusalKind` and `ExtractedBody` were reachable as a deep `dist/utils/extract-hook-body.js` import until #13123 sealed the surface, and an app's hook-body fidelity harness (hotcrm's `test/helpers/action-sandbox.ts`) consumes them to run the SAME body-only lowering `os build` ships through the real QuickJS runner, so a test executes what production executes rather than a lookalike. The #13123 body names exactly this remedy for an out-of-repo consumer — ratify the subpath as public surface rather than read `dist/` paths — and 17.3.0 applied it to `./console` for cloud's `objectos-runtime`; this applies it to the second consumer (#15325). `@objectstack/cli/hook-body` is a dedicated entry that re-exports those four names and nothing else; the deep `dist/` path stays sealed. Also admits `./package.json`, so the ordinary tooling idiom of reading a dependency's own manifest resolves again. + `cli` +- `984f1da` — `scoreMetadata` no longer scores a stack whose linter crashed as a perfect one. + `cli` +- `9c3fda5` — `os lint --eval --json` now carries the ADR-0112 error carriers on its generator-load failure, instead of a bare `{error}`. + `cli` +- `acab609` — `os serve`'s runtime state file is keyed by the PROJECT, not by the environment id alone — so two projects on one machine stop overwriting each other's supervision record. + `cli` +- `cf6b671` — `os create` now emits a project that installs outside this monorepo. + `cli` +- `ec0a6e7` — `backfillSummaryNulls` accepts `recomputeUndefinedOnEmpty` — a caller who KNOWS a `min`/`max`/`avg` roll-up column was just declared can have it filled; `os migrate summary-nulls --recompute-undefined-on-empty object.field` surfaces it (#15064) + `cli`, `objectql` +- `61b4eb3` — `client.meta.getHistory` answers the published `HistoryMetaItemResponse` on **both** of its exits, and the route ledger names the schema. + `client`, `rest` +- `68437d4` — The automation resume route's `400 FLOW_FAILED` now says whether the run is stranded. + `client`, `runtime`, `spec` +- `e08892d` — `cloneData` reports `droppedFields` like every other create face: `CloneDataResponseSchema` (`@objectstack/spec/api`) gains an optional `droppedFields` member of the same shape as `CreateDataResponseSchema`'s, and the `POST /data/:object/:id/clone` 201 body carries it whenever the engine stripped a static `readonly` column from the clone. + `client`, `metadata-protocol`, `spec` +- `236f2df` — Console (objectui) refreshed to `a472b07167a3`. Frontend changes in this range: + `console` +- `30b0990` — Console (objectui) refreshed to `53ded82bf7a4`. Frontend changes in this range: + `console` +- `2025b1f` — `kernel.use()` now enforces the declared plugin contract. A plugin object that `PluginSchema` (`@objectstack/spec`, `kernel/plugin.zod.ts`) refuses is refused at load instead of being stored and mounted. + `core` +- `2a3decc` — `PluginSchema` now REQUIRES `staticPath` and `slug` when `type` is `'ui'`, and core's `Plugin` interface inherits every `PluginSchema` key from `PluginDefinition` instead of restating two of them. + `core`, `spec` +- `2ed6be6` — Advisory validation rules no longer flood the startup log, and no longer count a row twice on a clean first boot. + `core`, `metadata-protocol`, `objectql` +- `51ae731` — `LiteKernel.use()` now enforces the declared plugin contract — the same check, the same refusal, as `ObjectKernel.use()`. A plugin object that `PluginSchema` (`@objectstack/spec`, `kernel/plugin.zod.ts`) refuses is refused at registration on **both** published kernels instead of on one. + `core` +- `66dc6ab` — Plugin startup elapsed time is now reported as `durationMs` — the unit-bearing name the spec contract for the same result declares. `startTime`, which never held a start time, is deprecated and still populated. + `core` +- `b0529e1` — `ResolvedAuthzContext.authRefusal` is removed — a published member nothing ever read (#14273) + `core` +- `cc00df2` — ADR-0087 semantic-migration ledger: register the retirement of `@objectstack/core`'s `PluginSecurityScanner` (#14919) + `core`, `spec` +- `cf9bda4` — The kernel's in-memory i18n fallback learns the declared `i18n.fallbackLocale`, so one declaration stops answering two ways (#15694) + `core`, `runtime`, `spec` +- `4998efa` — Scaffolded projects now ship a CI workflow, and a `lint` script for it to run. + `create-objectstack` +- `813d6c5` — `npx create-objectstack` now declares the same TypeScript range as `os init` and + `create-objectstack` +- `fd75728` — Scaffolded projects now install the AI skills bundle for **one** agent runtime + `create-objectstack` +- `1cf7392` — `driver-memory` analytics honours `AnalyticsQuery.timezone` when it resolves a string `dateRange`, instead of accepting the field and answering on UTC (#16042) + `driver-memory` +- `2003259` — `find()`, `findOne()` and `create()` publish their declared types (#14435) + `driver-memory` +- `5f4f1f6` — `driver-memory` analytics resolves `dateRange` on the UTC calendar, so `'today'` and `last N ...` stop being offset by the process timezone (#15825) + `driver-memory` +- `a646120` — The three SQL compilers in this package — the RLS read-scope lowering (`compileScopedFilterToSql`), `NativeSQLStrategy`'s own `where` and the `ObjectQLStrategy` SQL echo — compile a text operator over a column whose declared type stores no text to the contract's declared answer. + `driver-memory`, `driver-sql`, `driver-turso`, `service-analytics`, `spec` +- `2200f8e` — `update()` publishes its honest type — the contract's `Record | null`, not `any` (#14438) + `driver-sql`, `driver-turso` +- `33e939f` — Schema drift now reports a SINGLE-VALUE JSON-class column that a stale `varchar`/`text` column is holding — the population the detector could never see. + `driver-sql` +- `45cfa1b` — The record read doors present the builtin audit stamps (`created_at`, `updated_at`) and every declared `Field.datetime` column as the canonical instant text `YYYY-MM-DDTHH:MM:SS.sssZ` on EVERY dialect — Postgres and MySQL now included, exactly as SQLite always has (ADR-0053 addendum D-F1..D-F3, #13973). + `driver-sql` +- `54bb2f1` — The analytics SQL compilers compile the case-sensitive text family per dialect, so a `$contains` policy on SQLite stops admitting rows it excludes (#15684) + `driver-sql`, `service-analytics` +- `6d4d5d3` — `SqlDriver.aggregate` answers `0` — not `null` — for a `sum` over a group whose aggregand is NULL in every row, matching the engine's in-memory aggregate tier and the identity `emptyGroupValueFor` already declares (#15546; maintainer ruling 2026-09-07, option A: a non-empty group whose aggregand is absent and an empty group are the SAME case for `sum`, and the SQL face is the one that moves). + `driver-sql`, `driver-turso`, `spec` +- `7862fb7` — `SqlDriver`'s object-definition parameters now DECLARE every key they read. `initObjects` accepts `lifecycle`, and the whole rotation chain — `rotateShards`, `ensureRotation`, `ensureShardTable` — accepts `tenancy` and `indexes`, spelled as a **fresh object literal** rather than only as a value bound to a variable first. + `driver-sql`, `driver-sqlite-wasm`, `driver-turso` +- `b72226f` — `SqlDriver.initObjects()` and `SqlDriver.registerObjectMetadata()` now declare the `indexes` key they have always read. + `driver-sql`, `driver-sqlite-wasm` +- `098cbb7` — `validateExpression` now refuses a non-string expression `source` through `errors[]`, instead of throwing a raw `TypeError` that wiped out the caller's located reporting. + `formula` +- `cfb64a6` — `createHonoApp` mounts the auth surface where the auth service actually serves, and refuses a prefix it cannot serve it under. + `hono`, `plugin-auth` +- `36a16d0` — Two new widget-binding rule ids for a chart widget with an empty selection + `lint` +- `56fe8c2` — A flow predicate authored as a CEL envelope is now refused at build time, instead of running unread by either validator. + `lint`, `service-automation`, `spec` +- `615fac3` — A publish now refuses an object whose `highlightFields` names a field that does not exist on it — the same gate that refuses a code-authored stack. + `lint`, `metadata-protocol` +- `720bf47` — `flow-update-readonly-field` and `hook-api-update-readonly-field` now report a non-system **create** of a static-`readonly` field — a new **error**-severity finding that fails `os lint` / `os validate` / `os build` on a shape they used to accept. + `lint` +- `9408b7f` — A flow condition that is neither CEL text nor an expression is now refused at build time, instead of being read as an empty condition and answering a silent `false`. + `lint`, `service-automation`, `spec` +- `954cb0b` — an `assignment` value may be a CEL envelope — evaluated at run time, validated at `registerFlow`, `objectstack validate` and the runtime publish gate (#15137, the executor half of #14149) + `lint`, `service-automation` +- `9f890d3` — `filter-preset-comparand` gains a FIELD-TYPED arm (#16106, maintainer-ruled 1′): on a declared `date` / `datetime` field, a dashboard date-range preset name (`last_30_days`, `this_quarter`, …) is now refused in EVERY comparand position — bare (implicit equality), `$eq` / `$ne`, `$in` / `$nin`, and their view-rule (`equals` / `not_equals` / `in` / `not_in`) and triple (`=` / `!=` / `in` / `nin`) spellings — with the same located message and prescription the ordering positions already carry (`{ $gte: '{30_days_ago}' }` for `last_30_days`, and so on). The field type is read from the stack's own object graph: a dashboard widget or report through its `dataset` to that dataset's `object`, a view through `data.object`, a flow CRUD node through `config.objectName`, a page component through `dataSource` / `properties`, an object's own list views and `relatedListFilter`, a summary field's child object. A position the graph cannot bind, a registry-injected column, a `time` field, or a select / text column stays unjudged — equality against a picklist value that collides with a preset name is a working filter. The field-agnostic schema door in `@objectstack/spec` keeps its ordering-only boundary unchanged; this closes the authoring-time gap where `objectstack lint` and the runtime publish gate accepted a filter the engine then refused with `INVALID_FILTER` / 400 on first render. + `lint`, `spec` +- `a87163c` — New advisory rule `field-no-consumers` (`validateFieldConsumers`): a field declared on an object that nothing in the stack reads or displays is reported as a `warning` by `os validate`, `os build` and `os lint`. + `lint` +- `b4b37e5` — The object publish door now refuses an object whose `searchableFields` entry, or whose built-in list view's `columns` (and every other field-naming position on that list view), names a field the object does not have. + `lint`, `metadata-protocol` +- `c01b3a6` — `chart-field-unknown` drops to `warning` on the three `chartConfig` binding keys the pinned renderer refuses, and says what actually happens + `lint` +- `cd55558` — `widget-measures-missing` — the empty-measure selection is reported on every widget family, not just charts + `lint` +- `d61bad0` — New gating rule `flow-filter-token-unknown`: a `{…}` filter token in a flow node's `config.filter` that NEITHER `{…}` dialect can resolve is now an authoring-time `error`. + `lint` +- `3bd9b34` — `deriveViewContainerObject` gets a leaf `/view-container` subpath, so objectql's lean ADR-0076 entry stops loading the manager, chokidar, glob and js-yaml for a six-line pure function + `metadata`, `objectql` +- `8647c87` — A completed run of the ADR-0030 notification cut-over now records itself in the `sys_migration` deployment ledger, per the ruled claim matrix. + `metadata` +- `a56baa2` — a keyed plural read on `MetadataManager`, `listNames` fault parity, and an action audit that answers from the same identity and sources as the router + `metadata`, `objectql` +- `c1d274d` — two files sharing one stem are refused with both paths named, instead of one being listed twice and served by extension precedence (#14921) + `metadata` +- `6491463` — `/discovery` stops advertising a realtime service that has no mounted surface, and "what counts as a subscribable channel" becomes one explicit definition. + `metadata-protocol`, `runtime`, `spec` +- `c1d8f98` — Seed datasets gain a `locale` filter axis, composed with `env` by the loader. + `metadata-protocol`, `spec` +- `d2b6fa0` — Stop persisting the caller's `_packageId` / `_packageVersion` / `_provenance`, and restate tenant authorship at hydration for every metadata type. + `metadata-protocol` +- `11f848e` — `ReadonlyFieldRejectedError`'s error `code` is now an importable constant. + `objectql` +- `33388f9` — Engine refusals now declare their HTTP status under both spellings: `httpStatus` beside the existing `status`, same number, at every producer in the package. + `objectql` +- `48b0fcf` — `@objectstack/objectql` now publishes a recognizer for the org-less system-write refusal, so a consumer no longer has to choose between an unsound check and a re-spelled string. + `objectql` +- `6d7d740` — `ValidationError` publishes its error `code` as an importable constant — the last row of #16159's census. + `objectql` +- `7778115` — `ObjectQL.find()` now guarantees the array it declares: an `afterFind` hook that replaces the result container is refused with `FIND_HOOK_RESULT_NOT_ARRAY`. + `objectql`, `spec` +- `d0ee598` — the boot loop refuses a view container whose `name` disagrees with the object it binds to, instead of silently rewriting the author's field (#14666) + `objectql` +- `ddfbf04` — Three more engine refusals publish their error `code` as an importable constant. + `objectql` +- `e6279dc` — The registry's three conflict refusals now publish their error `code` as an importable constant. + `objectql` +- `eddd612` — Both transaction-seam refusals publish their error `code` as an importable constant. + `objectql` +- `f7ffbd6` — `accept-language: zh` now reads a Chinese refusal on the response whose labels are already Chinese. + `objectql` +- `f9a3c32` — the Layer 0 tenant wall records its verdict on the operation, and the bulk data-event producer reads it instead of re-deriving the wall + `objectql`, `plugin-security`, `spec` +- `fa125f3` — `Field.valueDomain` binds at the write seam — a non-member is refused with `value_domain` (maintainer ruling 2026-09-02 on #14168, engine half) + `objectql`, `spec` +- `c677cda` — Ship the multi-organization runtime as open source: `@objectstack/organizations` is now an + `organizations`, `plugin-security`, `service-cluster`, `spec` +- `0a038cc` — `sys_job.timezone` and `sys_report_schedule.timezone` are validated against the IANA domain (#15872) + `platform-objects` +- `4ca358d` — `sys_session.revoke_reason` accepts `organization_membership_ended` — "Remove member" now actually signs the person out + `platform-objects`, `plugin-auth` +- `6acb37e` — `sys_business_unit.timezone` and `sys_organization.timezone` — the organization hierarchy carries the IANA zone a date boundary is computed in (#14238) + `platform-objects`, `plugin-auth` +- `7797102` — `sys_organization` admits generic `update` on the data door, column-gated by the ADR-0092 D2 identity write guard (#15873 — maintainer ruling 2026-09-07, decision batch #64, option (a), verbatim 「同意」). + `platform-objects` +- `a83482c` — The three platform record pages carry a translated label in every shipped locale. + `platform-objects` +- `3d3f60e` — An approval decision that lands while its flow run strands now says so in fields, not only in prose. + `plugin-approvals`, `rest`, `types` +- `4c31f02` — The stranded-request inspection tells a repairable strand from a cascade-failed run — through a dedicated read-only engine member, not through the wire (#15358, ruling B′). + `plugin-approvals`, `service-automation` +- `6530e04` — A restored approval suspension can now be decided again, not only cancelled. + `plugin-approvals` +- `142c01c` — MCP OAuth can complete again: the MCP resource is registered as an RFC 8707 resource and DCR-registered clients are linked to it, so `authorize?resource=` no longer answers `invalid_target`. + `plugin-auth` +- `aedbaef` — `POST /sign-up/email` for an address that already has a `sys_user` row is refused explicitly, instead of answering 200 for a row that is never written (#15587) + `plugin-auth`, `spec` +- `5f7fa1d` — `GET /auth/me/localization` → `locale` is now the signed-in user's language — `sys_user.locale` when set, then the request's `Accept-Language`, then the deployment default (#14788) + `plugin-hono-server`, `spec` +- `6615a02` — the current-user faces assemble their `ExecutionContext` through the shared assembler (#15747) + `plugin-hono-server` +- `4db3c61` — `publicSharing.enabled` now has one canonical predicate, exported from the package that declares the key. + `plugin-sharing`, `runtime`, `spec` +- `9fa5775` — the `field` sharing recipient is enforced — expanded once per matched record + `plugin-sharing` +- `46803fa` — the authored label is the default locale's text — `ResolveOptions.defaultLocale` skips the fallback chain for a default-locale request, and a chain-less caller no longer falls to a literal `en` (#15711) + `rest`, `spec` +- `7370989` — `GET /api/v1/packages`, `GET /api/v1/packages/:id` and `DELETE /api/v1/packages/:id` have one implementation: the runtime dispatcher's `/packages` domain. `@objectstack/rest`'s `registerPackageRoutes` no longer mounts its own copies of those three routes; it mounts `POST /api/v1/packages/publish` and nothing else. + `rest` +- `784cb92` — every `metadata.endpoints.*` switch gates exactly the face its name states, and the whole-store operations get their own key `maintenance` (#15542, #15854) + `rest`, `spec` +- `a84e1ce` — `II18nService.getFallbackLocale()` — the declared fallback locale is readable, so the metadata-document translators can be handed the chain the deployment declared (#14882) + `rest`, `service-i18n`, `spec` +- `f5cc78b` — the generic declared-status passthrough names its object on both error doors (#14725) + `rest` +- `1ecee3e` — every `defineStack` refusal carries an ADR-0112 envelope — six new `STACK_*` codes beside `STACK_CROSS_REFERENCE_INVALID` (#15963) + `runtime`, `spec` +- `2c753fe` — a flow action's run context now carries `recordLoadDenied` (#15168) + `runtime`, `spec` +- `3e560da` — `GET /api/v1/health` answers 200 whenever the process can serve HTTP, even while a configuration fault is making every other route 503. + `runtime` +- `44c849c` — The two operator run-lifecycle verbs get a door: `POST /automation/:name/runs/:runId/cancel` and `POST /automation/:name/runs/:runId/restore-suspension`, both gated on the platform operator. + `runtime` +- `8a12067` — the platform action route executes the declarative row-level `operation: 'update'` action (#14092) + `runtime` +- `92b5d7f` — `POST /api/v1/packages` now answers an install-time namespace collision with `error.code: "NAMESPACE_CONFLICT"`. `NAMESPACE_CONFLICT` is registered in `ERROR_CODE_LEDGER` under `@objectstack/objectql`, so the closed ADR-0112 vocabulary (`StandardErrorCode ∪ ERROR_CODE_LEDGER`) gains one member and a caller can branch on the refusal directly. + `runtime`, `spec` +- `b31ebfe` — A screen flow can now be completed by a headless caller, and `list_actions` publishes its input names. + `runtime`, `service-automation` +- `bca21f7` — `POST /packages/:id/duplicate` now refuses a source that is not a writable base, instead of answering `200` with an empty copy. + `runtime`, `spec` +- `ce8bfc9` — Dispatcher domain routes stop claiming their lexical neighbours: `DomainRoute.match` now defaults to `'segment'`, and the project-membership skip list gained the same boundary. + `runtime` +- `de75e40` — The `/keys` mint gate and the install-wide activation-write gate classify a tenancy resolution failure instead of reading it as "no wall" + `runtime` +- `f2f6684` — Package lifecycle state is keyed by the PROJECT as well as the environment id, so two projects on one machine stop sharing which packages an operator has disabled. + `runtime` +- `07f40e5` — A dataset measure's `fields[].type` stops contradicting the value beside it: a `min`/`max` over a temporal field is described as `time`, not `number` (#15768) + `service-analytics`, `spec` +- `6136293` — A `min`/`max` over a string-valued field is described as `string`, not `number` (#16098) + `service-analytics` +- `6573af9` — A draft-preview `min`/`max` answers the operand's own type instead of `0`, and a preview dimension column is described by its own type + `service-analytics` +- `5964124` — a resume that consumed the pause and then failed downstream answers `status: 'stranded'` (#13937) + `service-automation` +- `5a91387` — `evaluateCondition` now refuses a malformed condition shape with the same `STRUCTURAL_CONDITION_SHAPE_REFUSAL` registration already raises — evaluation and registration share one refusal, so a shape that slipped past registration can never surface as a raw `TypeError` or as a silent `false`. + `service-automation` +- `65ec530` — The run step log tells a `parallel` branch apart from a `loop` row: `iteration` is the enclosing loop's iteration, always, and the branch index moves to `branch`. + `service-automation` +- `8341ed2` — A scheduled (cron) flow is now delivered once per tick window, and replaying a window that was already delivered is refused instead of silently sent again. + `service-automation`, `service-job`, `trigger-schedule` +- `d30ccb9` — A contained per-iteration failure is now visible at run level, attributed to its iteration, and bound to its row. + `service-automation` +- `6b8c677` — the settings door answers from the ONE shared value-domain predicate, and refuses a non-member with `value_domain` (#15162) + `service-settings` +- `dd2184a` — `mountStorageRoutes` — mount the storage routes on a host-owned HTTP surface, composed from a kernel that has no `http-server` service (#15169) + `service-storage` +- `13c48c2` — retire `connector.errorMapping` — eleven authorable keys nothing ever read, one of them spelled like the live `userMessage` channel (#14676, ADR-0049) + `spec` +- `222dc0f` — `IJobService.replay` gains an optional third argument, `options?: JobReplayOptions`, carrying `force: true` (#14766 — the contract half of the #14501 A+a2 ruling) + `spec` +- `3e3ecb0` — The model-facing solution-blueprint mirror can no longer generate an identifier the applier rejects. + `spec` +- `3f89967` — A flow can now REFUSE with per-record text: the `end` node gains `outcome` and an interpolated `message`, and the run vocabulary gains `refused`. + `spec` +- `4cfc93b` — `objectNavTargetExclusivity` — the object-level check on an object navigation item that refuses `filters` combined with `recordId` / `viewName`, and `runAction` combined with `recordId` — is now EXPORTED from `@objectstack/spec/ui`, one function per refinement in the same posture as the `check*` exports. A hand-written mirror of the object nav item chains this very function in its own `superRefine` instead of restating the rule from prose; a restatement is what drifts: objectui's hand-written mirror re-implements neither rule — its `superRefine` checks only `id` / `label`, and the file names no `filters` rule beyond the field's declaration (measured at the pinned `.objectui-sha` and at objectui `origin/main`). + `spec` +- `554a160` — `translatePage` now reads a region-level `page:header` by **page name only**. The id route + `spec` +- `581d8f8` — `TryCatchErrorValueSchema` declares the `code` key the `try_catch` engine binds (#14954) + `spec` +- `613bfbd` — register the fourteen remaining `door: 'none'` error codes that ship in `dist` — the rest of the #16404 class after #16449 enters `ERROR_CODE_LEDGER` (#16649) + `spec` +- `625b0c3` — `PLATFORM_TOOLS_BY_PACKAGE['service-ai-studio']` lists the five tools the cloud AI runtime registers that it had been omitting: `get_authoring_rules`, `load_tools`, `open_record`, `test_flow` and `toggle_flow`. Added in the list's existing alphabetical order; nothing else in the registry moves. + `spec` +- `68d5dfd` — `ExecutionStepMetrics` gains an optional `failures` slot, and `FlowRunSummary.failed` is declared as the fold INCLUDING what a delegating node rolled up from its child (maintainer ruling 2026-09-06 on #15617, spec half) + `spec` +- `69602e5` — export `COMPOSE_KEY_DISPOSITIONS` and `STACK_DEFINITION_KEYS` — the artifact envelope's top-level key set and each key's composition rule, derivable from one source instead of hand-copied per consumer (#14877) + `spec` +- `6ba0db4` — declare the aggregate × field-type compatibility matrix a dataset measure is judged against — `AGGREGATE_FIELD_TYPE_COMPATIBILITY` and `isAggregateCompatibleWithFieldType` (#16353, spec half of #16099) + `spec` +- `7936b29` — export the object-level refinement checks the mirrored UI schemas run — `checkListViewPageMount`, `checkListViewCalendarVisualization`, `checkPageSourceCompleteness`, `checkGlobalFilterDateDefaultValue` (#16489, the spec half of objectui#7715) + `spec` +- `8333a6c` — A blueprint nav entry can say WHICH view it opens: `viewName` is added to `BlueprintNavItemSchema` and, in lockstep, to the strict mirror's `StrictNavItem` (required-but-nullable, per the strict convention). + `spec` +- `859ded3` — `FieldSchema` refuses a WHITESPACE-ONLY `reference` on `lookup` / `master_detail` + `spec` +- `87f0ccc` — `SharingRuleEvaluationResult` declares `grantsRefused?: number` — the optional seventh key the sharing-rule evaluate route already answers (#14969) + `spec` +- `a7da4de` — `adr-0030-notification-event` joins `CREATION_ATTESTED_MIGRATION_IDS`, and its docblock states what a run may claim in the `sys_migration` ledger (maintainer ruling 2026-09-05 on #15710) + `spec` +- `abae16a` — register every error code that ships in `dist` — `OBJECT_OWNERSHIP_CONFLICT`, the seven `STACK_*` `defineStack` refusals and `PLUGIN_UI_REQUIRED_KEY_MISSING` enter `ERROR_CODE_LEDGER` (#16449) + `spec` +- `abb140c` — declare the two operator run-lifecycle verbs on `IAutomationService` — `cancelRun` and `restoreConsumedSuspension` (#16495, the contract half of #13953) + `spec` +- `bf1054a` — retire the fourteen inert deadline keys of the incident-response, training and change-management schemas (#14477, ADR-0049) + `spec` +- `c2a336c` — `@objectstack/spec/system` now names the ADR-0030 notification cut-over, so "has this deployment run it?" has a place to be answered. + `spec` +- `c7aca0d` — `ResumeFailureReport` — the machine-readable half of a resume failure, declared once in `contracts/approval-service.ts` and carried as the optional, additive `resumeFailure` member of `ApprovalRecallResult` and `ApprovalDecisionResult` (the contract half of the #16472 family ruling, decision batch #76). + `spec` +- `de0bcdd` — `ComponentPropsMap['object-kanban']` declares `limit`, the row cap four objectui faces already implement (#16503, the spec half of objectui#8172) + `spec` +- `e89fa92` — `IDataDriver` now declares `aggregate?` — the one engine-reached driver verb that had no signature to match against. + `spec` +- `ed7243d` — `AGGREGATE_FIELD_TYPE_COMPATIBILITY` accepts `boolean` / `toggle` for `sum` / `avg` / `min` / `max` — ruling #11152 (booleans aggregate as numbers on every backend) stands over batch #59's blanket default (#16685) + `spec` +- `f502898` — list-view grouping is server-side — the group header query and the per-group row page compile from the view (#14556) + `spec` +- `fe0d9a4` — `action.ai.requiresConfirmation` gets a real contract — a DECLARATION, not yet a live gate: the contract states that an AI-facing call on an action declaring the flag must carry an explicit confirmation and is refused without it, with a refusal that tells the caller how to retry. No door performs that refusal yet; setting the flag still stops nothing until the runtime half lands. + `spec` + +## Fixes in 17.4.0 + +221 `patch` changesets, ⛔ not enumerated here — a patch entry is by construction not a +new capability, and listing all of them is what the per-package `CHANGELOG.md` files are for. +Distribution across the packages they touched: + +- `spec` — 50 +- `cli` — 38 +- `lint` — 23 +- `runtime` — 15 +- `rest` — 13 +- `platform-objects` — 12 +- `service-automation` — 12 +- `metadata-protocol` — 10 +- `objectql` — 9 +- `core` — 8 +- `metadata` — 7 +- `plugin-auth` — 7 +- `driver-sql` — 6 +- `plugin-approvals` — 5 +- `service-analytics` — 5 +- `client` — 4 +- `plugin-sharing` — 4 +- `plugin-security` — 4 +- `plugin-webhooks` — 3 +- `plugin-email` — 3 + +…and 35 further packages with fewer entries. + +## Upgrade checklist + +⚠️ One checklist per release, for the release you are landing on **and** every release you +cross to get there — and see +[how far each list has actually been walked](/docs/releases/v17#upgrade-checklists). + +⛔ **Not written.** An upgrade checklist is a walked route, not a restatement of the breaking +list above; the v17 page is explicit that a step nobody has run, presented beside steps that +were, is how a reader finishes a checklist and believes they are done. Nobody has walked +17.3.0 → 17.4.0. Until someone does, read +[Breaking changes & migration in 17.4.0](#breaking-changes--migration-in-1740) directly and +treat every line in it as unproven about what it costs to cross. diff --git a/content/docs/releases/v17/index.mdx b/content/docs/releases/v17/index.mdx new file mode 100644 index 0000000000..1104a2e3b7 --- /dev/null +++ b/content/docs/releases/v17/index.mdx @@ -0,0 +1,203 @@ +--- +title: v17 +description: "The v17 line — a truth-telling release. Files become owned records, the export privilege stops riding on read, the SDK is reconciled against the routes the server mounts, and a boot that cannot reach its datasource stops pretending it can. Per-release notes for 17.0.0 through 17.4.0." +--- + + +**The v17 line** is a truth-telling release. Where v16 made *declared metadata* +honest, v17 does the same for the surfaces around it: files stop being inline +blobs and become owned `sys_file` records with a governed download path; the +export privilege stops being a free rider on read; the SDK stops shipping +methods no server ever answered; a datasource that cannot connect stops booting +clean and failing every query afterwards; and an approval request stops being +readable by everyone in the tenant. Alongside that, `agent.tools[]`, the +GraphQL surface, the `ObjectStackProtocol` alias, and a long tail of +parsed-but-never-enforced spec clusters are removed rather than maintained. + +> **Release status: 17.4.0 is released**, and is the current version of the v17 +> line. It was published on 2026-09-09, taking over from +> 17.3.0 — published 2026-09-04, which took over from +> 17.2.0 — published 2026-08-23, which took over from 17.1.0 — published +> 2026-08-20, which took over from 17.0.0 — published 2026-08-14, closing a +> train that ran through `17.0.0-rc.0` … `rc.6` (the last of them cut +> 2026-08-10). A plain install now resolves 17.4.0. `changeset pre +> exit` ran with the 17.0.0 cut, so the `@objectstack/*` packages no longer +> publish as `17.0.0-rc.N`. Caret ranges on `^16.x` hold at 16.x until you opt +> in, which is the reason this train is a major at all: its breaking density +> (the `ApiMethod` shrink, the GraphQL removal, the ADR-0104 write cutover, the +> dead-cluster retirements) is too high to auto-upgrade `^16.x` consumers into +> on their next install. +> +> ⚠️ **17.1.0, 17.2.0, 17.3.0 and 17.4.0 are minors by version number, not by +> blast radius. Moving between them is not a tag swap.** Several of 17.1.0's security +> corrections change who can read or write on an existing deployment — read its +> upgrade checklist below. 17.2.0 adds write-path accept-set tightenings of the +> same shape: a by-id `update`/`delete` that used to silently drop an extra +> `where` predicate, or a mismatched `data.id` / `where.id`, now refuses loudly +> instead (#11009, #11142). 17.3.0 goes further still: it renames a published +> SDK namespace with **no aliases** (`client.projects.*` → +> `client.environments.*`), flips the self-registration default to +> `invite_only`, starts enforcing `unique` / `indexes[]` on `driver-memory`, +> makes a permission-store outage fail loudly, and tenant-scopes +> `sys_record_share` with an operator-invoked backfill for the rows written +> before it. Read **[Breaking changes & migration in +> 17.2.0](/docs/releases/v17/17.2#breaking-changes--migration-in-1720)** and **[Breaking changes & +> migration in 17.3.0](/docs/releases/v17/17.3#breaking-changes--migration-in-1730)** before upgrading. +> +> ⛔ **17.4.0's notes are a machine draft, not curated.** 396 changesets across 61 +> packages landed in it, 35 of them carrying a breaking marker, and no editorial +> pass has been made — see **[17.4.0](/docs/releases/v17/17.4)**, which says so at +> the top and lists what it does and does not cover. Its upgrade checklist is +> **not written**: nobody has walked 17.3.0 → 17.4.0. + + +## Per-release notes + +Each release below is a self-contained page: what it changed, what breaks, and its own upgrade checklist. + +- **[17.4.0](/docs/releases/v17/17.4)** — current +- **[17.3.0](/docs/releases/v17/17.3)** +- **[17.2.0](/docs/releases/v17/17.2)** +- **[17.1.0](/docs/releases/v17/17.1)** +- **[17.0.0](/docs/releases/v17/17.0)** — the major cut ([pre-release train](/docs/releases/v17/17.0-prerelease)) + +## Upgrade checklists + + +One checklist per release, for the release you are landing on **and** every +release you cross to get there. + + +**How far each list below has actually been walked.** The 17.3.0 list is the only +one on this page written against a **measured upgrade**: an application +repository was moved 17.2.0 → 17.3.0 by a reader holding nothing but the +published documentation, the `CHANGELOG.md` files inside the published npm +tarballs, and the `os` CLI's own output. Every line it produced is marked +**measured**, and several of them are changes whose own changelog entries are not +marked breaking — which is exactly why a checklist is not a restatement of +[Breaking changes & migration in 17.3.0](/docs/releases/v17/17.3#breaking-changes--migration-in-1730). + +⛔ **That run covered one hop, 17.2.0 → 17.3.0. Nobody has walked 17.1.0 → +17.2.0.** Every line in the 17.2.0 list is derived from a change's own +**Migration** note and is marked **not exercised**: accurate about what changed, +unproven about what it costs to cross. The two are kept apart on purpose — a step +nobody has run, presented beside steps that were, is how a reader finishes a +checklist and believes they are done. + + + +Per-release checklists: [17.4.0](/docs/releases/v17/17.4#upgrade-checklist) · [17.3.0](/docs/releases/v17/17.3#upgrade-checklist) · [17.2.0](/docs/releases/v17/17.2#upgrade-checklist) · [17.1.0](/docs/releases/v17/17.1#upgrade-checklist) · [17.0.0](/docs/releases/v17/17.0#upgrade-checklist) + +## References + +ADR-0104 (field runtime value-shape contract / file-as-reference) · ADR-0105 +(group tenancy posture) · ADR-0106 (metadata-plane FLS, proposed) · ADR-0108 +(membership grade is not capability) · ADR-0109 (agent tools from skills) · +ADR-0076 D9/D11 (protocol alias dissolution, dispatcher decomposition) · +ADR-0087 D4 (change manifest / `migrate meta`) · ADR-0049 (enforce-or-remove) · +ADR-0078 (loud at the producer) · ADR-0090 D3 (`team` recipient) · +#3825 (Node 22) · #3544/#3710 (export axis) · #3543/#3391 (ApiMethod +derivation) · #3760 (user-less runs) · #3855 (alias retirement) · +#3820 (agent authoring) · #3590 (approval visibility) · #3865 (sharing `full`) · +#3617 (files-to-references migration) · #3447 (dynamic approver routing) · +#3563/#3587/#3612/#3718 (route ledger + SDK surface) · #2462 (GraphQL removal) · +#3741/#3758/#3826 (datasource fail-fast) · #3696 (per-tenant `unique`) · +#3676/#3778/#3847 (i18n contract conformance). + +Landed since rc.0: ADR-0110 (action declaration admission) · ADR-0111 (sharing +authority) · ADR-0112 (error-code vocabulary) · ADR-0113 (`required` split) · +ADR-0114 (field-error catalog) · ADR-0115 (no fabricating fallbacks) · +ADR-0116 (declared plugin ordering) · ADR-0053 (temporal semantics) · +#3962/#3951 (actions speak HTTP) · #3915 (action type dispatch) · +#4121/#4134/#4164/#4181/#4226/#4254/#4256/#4363 (list queries apply or fail) · +#3795/#4346/#4371 (one alias fold) · #3899/#3878 (request-body validation) · +#3939/#3897/#3933/#3946/#3960 (bulk binding + caps) · #3843/#3983/#4038/#4053 +(envelope convergence) · #3957 (localized validation) · #4093/#4113/#3891/#4087 +(honest absence) · #4073 (Hono transport adapter) · #4065/#4083 (memory-driver +persistence) · #4246 (`artifact-api` removal) · #3903 (stored-metadata +conversion replay) · #4277/#4045/#4027/#4347 (flow config enforcement) · +#3948/#4029 (view-filter operator parity) · #4157/#4128 (analytics stops +guessing) · #3916 (report ordering) · #4350 (protocol-17 relabel) · +#4127/#4251 (typed service slots + fail-open fixes) · #3917/#3924 (`os migrate` +occupancy + deferred DDL) · #4243/#4270 (platform-objects infrastructure) · +#4395/#4396 (unmeasured effects) · #4365/#4366 (approval reassign + audit +attribution) · #4261/#4248 (published-files hygiene). + +Landed since rc.1: ADR-0118 (non-user actor contract) · ADR-0119 +(plugin-reachable transactions, migration journal) · ADR-0088 (metadata-kind +admission) · ADR-0103 (`managedBy` buckets) · #4535 + #4411 (dual-source +convergence C1–C17: #4572, #4587, #4610, #4641, #4653, #4658, #4661, #4684, +#4688, #4691, #4703, #4737, #4738, #4739, #4740, #4741) · #4537/#4538/#4539 +(enum, contracts and cross-form convergence) · #4446 (symbol-identity ratchet) · +#4001 final batches (#4514/#4519/#4522/#4527/#4528/#4529/#4530/#4531/#4532/#4533/#4534/#4541) · +#4544 (ADR-0078 completeness, Phases 1/3/4: #4501, #4565, #4574, #4599) · +#4463 (runtime authoring gate) · #4409/#4487/#4488 (rule + liveness coverage) · +#4583/#4634/#4484/#4618/#4579/#4657/#4673/#4616 (enforce-or-remove: datasource, +driver and contract surfaces) · #4509 (validation kind, job door, doc.tags, +email-template bridge) · #4667 (authorWarn keys) · #3355 (`system-data`) · +#4612/#4617/#4620 (atomicity + migration journal) · #4793 (batch row shape) · +#4639/#4626/#4602/#4678 (event contracts + client-react) · #4343 (`script` +node) · #4649/#4770/#4775/#4784 (predicate and condition semantics) · +#4419 (`findOne`) · #4462/#4410/#4456 (datasource routing + config contract) · +#4630/#4651/#4722/#4757 (security corrections) · #4433/#4434/#4640/#4669 +(sharing + permission corrections) · #4467/#4437/#4442/#4708/#4820 (analytics) · +#4327/#4454/#4542 (stored-metadata migration). + +Landed since rc.2: ADR-0122 (parsed/author state naming: #5551, #6350 phase 2) · +ADR-0045 (app publish gate: #4829) · ADR-0097 §3 (connector credential +references) · #4936/#4939/#5111/#5040 (declarative `apis:` goes live) · +#6345 (one driver vocabulary, `mongo` → `mongodb`) · +#6212/#6075/#6320/#6321 (driver query contract narrowing) · +#5488 (`api` is code-only) · #5561 (`resumeAuthority` fail-closed) · +#6361 (notification `cursor` retirement) · #5775/#6776 (SDUI component props) · +#6239 (`ViewProtocol` retirement) · #6188 (aggregation function narrowing) · +#5051 (`composeStacks` i18n) · #5945 (`HookContext.api`) · #6139 +(`HierarchyScopeContext` posture) · #5599 (view union identity) · +#4671 (`system-data` import) · #5696/#5351 (transaction tightening) · +#6536 (`ExportFieldMeta`) · #6704 (`runAutomations` default) · +#6748 (`ActionDescriptor.isAsync`) · #6771 (overlay index producer). + +Landed since rc.6: ADR-0123 D2 (active-organization write refusal: +#8247/#8208) · #7990/#8082/#8336/#8075 (inline credentials refused at publish) · +#7986/#7799/#7722/#8022/#8542/#8558 (webhook credential handling) · +#7522/#8273 (settings crypto fail-closed + wire code) · #7728 (`internal: true`) · +#8136/#8333/#8441/#8442/#8443/#8086/#8502 (driver-text disclosure) · +#8323/#8468/#8554/#8555/#8577/#8459/#8375 (per-organization uniqueness) · +#7626 (expand disclosure) · #7835/#7738/#8119 (federated tenancy) · +#8095/#8240/#7761/#7795/#8158 (org-scoped sharing + invitation reads) · +#5222/#7597/#7598/#8220/#7929/#7988 (`$field` cross-field comparison) · +#7606 (closed query-parameter ingress) · #6682/#6518 (`$contains` case +sensitivity) · #8010 (`view.exportOptions`) · #7176 (list-view passthrough +keys) · #7893 (`field.allowRuntimeCreate`) · #8057 (`engine.update` upsert) · +#7481 (auth config flags) · #7596 (list comparand references) · #7496 +(`submitBehavior.url`) · #7780 (cross-tenant uninstall) · #6915 (memory-driver +tenancy) · #7501/#7918/#7768 (number and currency contracts) · +#8445/#8567/#8592 (unbacked conflict targets) · #8269/#8283 (autonumber) · +#7580/#5346/#5376 (MongoDB query surface) · #8144/#8145/#8594/#8400 (audit +writers) · #7533/#8050/#7639/#7546 (automation run durability) · +#8368 (`OS_ARTIFACT_URL`) · #5320/#8070 (`viewItems:`) · #7616/#8326 (security +service surface) · #8600/#7891 (authored OWD at the object door) · +#8309/#8307/#7220 (security posture at runtime publish) · #7751 (`object-*` +block props) · #8315/#7675 (audit action enum) · #7735/#8019 (change-email) · +#7762/#7978/#7685 (verification harness). + +**17.1.0** — ADR-0049 (enforce-or-remove) · ADR-0055 (`controlled_by_parent`) · +ADR-0066 D3 (field `requiredPermissions`) · ADR-0067 (revert plans) · ADR-0068 +(platform-admin gate) · ADR-0090 D1 (OWD baseline) · ADR-0105 D7 (extension +fields) · ADR-0110 D3 (failure is not emptiness) · ADR-0111 D7 (inert grants) · +ADR-0112 (error-code vocabulary, amended 2026-08-18) · ADR-0120 D3 (partitioned +uniqueness) · #8613 (`active` stops granting) · #8710 (deactivated position, +sharing rules) · #8681 (admin export wildcard) · #8993 (partial field masking) · +#8652 (read-only approval visibility) · #8155 (credential re-homing) · #8287 +(API-key organization) · #8778 (`tenancy.organizationField`) · #9934 +(`userMessage`) · #9106 (`error.code` closed at every door) · #9446 / #9462 +(flow-dispatch status table) · #8896 (four read seams) · #7529 / #9612 / #9176 +(runtime publish gate) · #8687 (strict top-level stack keys) · #9013 (dashboard +`modal` target) · #9698 (sharing-rule enforceability) · #9257 (list-view `sort`) · +#8934 (`icontains`) · #8704 (`relatedListFilter`) · #9566 / #9474 +(`ActionSchema.onSuccess`) · #9225 / #9205 (email template rendering) · #8928 +(`os migrate duplicates`) · #9666 (scaffolds compile) · #8978 (ready banner) · +#8985 (`OS_INTERNAL_ARTIFACT_PATH`) · #9683 (`storage` slot) · #9730 +(`delegated_from`) · #8926 / #8807 / #8755 / #8622 / #9354 (MySQL driver) · +#9040 / #8337 / #9041 / #9091 / #9147 / #8495 (credential refusals at publish) · +#8873 / #8874 / #8696 (credentials reach the driver). diff --git a/content/docs/releases/v17/meta.json b/content/docs/releases/v17/meta.json new file mode 100644 index 0000000000..192d53c8b0 --- /dev/null +++ b/content/docs/releases/v17/meta.json @@ -0,0 +1,12 @@ +{ + "title": "v17", + "pages": [ + "index", + "17.4", + "17.3", + "17.2", + "17.1", + "17.0", + "17.0-prerelease" + ] +} diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index 5bd97108de..d9f4ba9376 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -295,10 +295,11 @@ have no other channel to reach you than the page below. | Release | Where its upgrade notes live | | :--- | :--- | -| v17.3.0 | [Upgrade checklist — 17.3.0](/docs/releases/v17#1730) | -| v17.2.0 | [Upgrade checklist — 17.2.0](/docs/releases/v17#1720) | -| v17.1.0 | [Upgrade checklist — 17.1.0](/docs/releases/v17#1710) | -| v17.0.0 | [Upgrade checklist — 17.0.0](/docs/releases/v17#1700) | +| v17.4.0 | [17.4.0](/docs/releases/v17/17.4) — ⛔ checklist not written; machine-draft notes only | +| v17.3.0 | [Upgrade checklist — 17.3.0](/docs/releases/v17/17.3#upgrade-checklist) | +| v17.2.0 | [Upgrade checklist — 17.2.0](/docs/releases/v17/17.2#upgrade-checklist) | +| v17.1.0 | [Upgrade checklist — 17.1.0](/docs/releases/v17/17.1#upgrade-checklist) | +| v17.0.0 | [Upgrade checklist — 17.0.0](/docs/releases/v17/17.0#upgrade-checklist) | | v16.0.0 | [Upgrade checklist](/docs/releases/v16#upgrade-checklist) | | v15.0.0 | [Upgrade checklist](/docs/releases/v15#upgrade-checklist) | | v14.0.0 | [Upgrade checklist](/docs/releases/v14#upgrade-checklist) | diff --git a/scripts/check-release-notes.mjs b/scripts/check-release-notes.mjs index e64f4f3710..433a05f565 100644 --- a/scripts/check-release-notes.mjs +++ b/scripts/check-release-notes.mjs @@ -83,9 +83,15 @@ if (!inRootNav && !onDocsHome) { for (const major of releasedMajors()) { if (major < FLOOR_MAJOR || KNOWN_MISSING.has(major)) continue; const slug = `v${major}`; - if (!existsSync(`${RELEASES_DIR}/${slug}.mdx`)) { + // Two layouts, both first-class, and this gate owns EXISTENCE only: one flat + // `v16.mdx`, or a `v17/` folder whose `index.mdx` is the major's landing page + // with one file per minor beside it. Either satisfies "the major has a + // curated, navigable page" — the nav entry is the folder name in both cases, + // so the `metaPages` check below is unchanged. ⛔ Neither layout is deprecated. + if (!existsSync(`${RELEASES_DIR}/${slug}.mdx`) && !existsSync(`${RELEASES_DIR}/${slug}/index.mdx`)) { problems.push( - `${RELEASES_DIR}/${slug}.mdx is missing — @objectstack/spec shipped a ${major}.x ` + + `${RELEASES_DIR}/${slug}.mdx (or ${RELEASES_DIR}/${slug}/index.mdx) is missing — ` + + `@objectstack/spec shipped a ${major}.x ` + `release but there is no curated release page. Write it (lead with breaking ` + `changes + migration), then add "${slug}" to ${META_PATH}.`, ); diff --git a/scripts/check-release-page-status.mjs b/scripts/check-release-page-status.mjs index b256c6d033..22889faf1e 100644 --- a/scripts/check-release-page-status.mjs +++ b/scripts/check-release-page-status.mjs @@ -972,10 +972,18 @@ function main() { problems.push(`${INDEX_PATH} is missing — there is no releases index to check entries against.`); } for (const major of checked) { - const pagePath = `${RELEASES_DIR}/v${major}.mdx`; - if (!existsSync(pagePath)) { + // Two layouts, both first-class. v16 and earlier are one flat `v16.mdx`; + // v17 onwards is a folder whose `index.mdx` is the major's landing page, + // with one file per minor beside it. The status blockquote lives on the + // landing page in both, so this gate only has to find it — ⛔ it does not + // care which layout a major uses, and ⛔ neither layout is deprecated. + const pagePath = [`${RELEASES_DIR}/v${major}.mdx`, `${RELEASES_DIR}/v${major}/index.mdx`].find( + existsSync, + ); + if (pagePath === undefined) { problems.push( - `${pagePath} is missing — @objectstack/spec ${major}.x is GA but there is no release page to ` + `${RELEASES_DIR}/v${major}.mdx (or ${RELEASES_DIR}/v${major}/index.mdx) is missing — ` + + `@objectstack/spec ${major}.x is GA but there is no release page to ` + 'check. (check:release-notes is the gate that owns page existence; this one owns what the ' + 'page SAYS.)', ); diff --git a/scripts/role-word-baseline.json b/scripts/role-word-baseline.json index 101e085dac..2461893872 100644 --- a/scripts/role-word-baseline.json +++ b/scripts/role-word-baseline.json @@ -31,7 +31,7 @@ "content/docs/releases/v13.mdx": 15, "content/docs/releases/v14.mdx": 5, "content/docs/releases/v16.mdx": 5, - "content/docs/releases/v17.mdx": 2, + "content/docs/releases/v17/17.0.mdx": 2, "content/docs/ui/forms.mdx": 3, "skills/objectstack-ai/SKILL.md": 1, "skills/objectstack-automation/SKILL.md": 1, From 56a8d433d52642410e12bea77fd2f01414473070 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 06:40:54 +0000 Subject: [PATCH 2/5] docs(releases): nest the 17.0 pre-release archive under 17.0, and give 17.4.0 the sibling section shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to the split. The rc.0 … rc.6 archive sat at the top level of `v17/`, so the sidebar listed it as a peer of the five real releases. It is not a release — it is 17.0.0's own pre-history — so 17.0.0 becomes a folder and the archive moves inside it: v17/17.0/index.mdx the release v17/17.0/prerelease.mdx the train that preceded it v17/17.0/meta.json The archive is now one level down, reachable by expanding 17.0.0 rather than sitting beside it, and its URL says the same thing: `/docs/releases/v17/17.0/prerelease`. Its title is now "Pre-release train (rc.0 … rc.6)" so the nav entry reads as an archive without opening it. The minors that have no sub-pages stay flat files; a minor becomes a folder only when it has something to nest. 17.4.0 now carries the same section shape as 17.1.0 through 17.3.0 — "Highlights — 17.4.0", "What's new in 17.4.0" with "Breaking changes & migration", "New capabilities", "Fixes" and "New in Console (Studio)" beneath it, then "Upgrade checklist" — so a reader crossing releases lands where they expect. Only the shape is finished. The two sections no mechanical pass can produce say so where they stand instead of being filled with a guess: Highlights is a judgement about audience, and the upgrade checklist is a walked route nobody has walked for 17.3.0 → 17.4.0. The Console section names the pin endpoints it can measure — `00d3f09c500c` (the last of 17.3.0's four moves, per that page) to `53ded82bf7a4` (the value of `.objectui-sha` on this tree, bumped by 30b09907) — and states plainly that the intermediate bumps are NOT enumerated, because this checkout's history for that file does not carry them. Naming the gap beats guessing the chain. `role-word-baseline.json` follows the move: one row, path only, `v17/17.0.mdx: 2` to `v17/17.0/index.mdx: 2`. Count unchanged, nothing else re-baselined. check-release-notes, check-release-page-status, check-role-word, check-doc-anchors (337 fragment links, all resolving) and check-docs-single-h1 all pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016M1gHPfToXY4sUdzsD2CHa --- .../releases/v17/{17.0.mdx => 17.0/index.mdx} | 2 +- content/docs/releases/v17/17.0/meta.json | 7 +++ .../prerelease.mdx} | 2 +- content/docs/releases/v17/17.4.mdx | 60 ++++++++++++------- content/docs/releases/v17/index.mdx | 6 +- content/docs/releases/v17/meta.json | 3 +- scripts/role-word-baseline.json | 2 +- 7 files changed, 52 insertions(+), 30 deletions(-) rename content/docs/releases/v17/{17.0.mdx => 17.0/index.mdx} (99%) create mode 100644 content/docs/releases/v17/17.0/meta.json rename content/docs/releases/v17/{17.0-prerelease.mdx => 17.0/prerelease.mdx} (99%) diff --git a/content/docs/releases/v17/17.0.mdx b/content/docs/releases/v17/17.0/index.mdx similarity index 99% rename from content/docs/releases/v17/17.0.mdx rename to content/docs/releases/v17/17.0/index.mdx index 88c3635e9f..6b1dd83345 100644 --- a/content/docs/releases/v17/17.0.mdx +++ b/content/docs/releases/v17/17.0/index.mdx @@ -1580,7 +1580,7 @@ This section covers the window bundled at **`rc.0`**: `cf2d56e32a11 → > The `17.0.0-rc.0` … `rc.6` train that preceded this cut is archived at -> **[17.0.0 pre-release train](/docs/releases/v17/17.0-prerelease)**. +> **[17.0.0 pre-release train](/docs/releases/v17/17.0/prerelease)**. ## Upgrade checklist diff --git a/content/docs/releases/v17/17.0/meta.json b/content/docs/releases/v17/17.0/meta.json new file mode 100644 index 0000000000..f4f2359068 --- /dev/null +++ b/content/docs/releases/v17/17.0/meta.json @@ -0,0 +1,7 @@ +{ + "title": "17.0.0", + "pages": [ + "index", + "prerelease" + ] +} diff --git a/content/docs/releases/v17/17.0-prerelease.mdx b/content/docs/releases/v17/17.0/prerelease.mdx similarity index 99% rename from content/docs/releases/v17/17.0-prerelease.mdx rename to content/docs/releases/v17/17.0/prerelease.mdx index 3921ef1839..e47143838e 100644 --- a/content/docs/releases/v17/17.0-prerelease.mdx +++ b/content/docs/releases/v17/17.0/prerelease.mdx @@ -1,5 +1,5 @@ --- -title: 17.0.0 pre-release train +title: Pre-release train (rc.0 … rc.6) description: "Archive: what landed between 17.0.0-rc.0 and rc.6, before the 17.0.0 GA cut. Kept for provenance; nobody upgrading between shipped versions needs this page." --- diff --git a/content/docs/releases/v17/17.4.mdx b/content/docs/releases/v17/17.4.mdx index ec516a821c..8cb4cf5163 100644 --- a/content/docs/releases/v17/17.4.mdx +++ b/content/docs/releases/v17/17.4.mdx @@ -4,31 +4,41 @@ description: "Release notes for 17.4.0 of the v17 line, published 2026-09-09. --- -⚠️ **This page is a machine draft, not curated release notes.** It was generated by -de-duplicating the **396 changesets** that make up 17.4.0 across all 61 package -`CHANGELOG.md` files (a changeset that bumps six packages appears once here, not six -times), and taking each one's own headline verbatim. +⚠️ **This page is a machine draft, not curated release notes.** It carries the same section +shape as [17.3.0](/docs/releases/v17/17.3) and its siblings, so a reader lands where they +expect — but only the shape is finished. The content was generated by de-duplicating the +**396 changesets** that make up 17.4.0 across all 61 package `CHANGELOG.md` files (a changeset +that bumps six packages appears once here, not six times) and taking each one's own headline +verbatim. -⛔ **What it is not.** No editorial judgement has been applied: nothing has been dropped for -being internal, nothing has been merged into a themed narrative, nothing has been ranked, and -the Highlights section every other release page carries is deliberately absent rather than -guessed. Deciding what is *user-facing* is a judgement read out of these entries, and it is -the maintainer's at release time. +⛔ **What has not happened.** No editorial pass: nothing dropped for being internal, nothing +merged into a themed narrative, nothing ranked. Deciding what is *user-facing* is a judgement +read out of these entries, and it is the maintainer's at release time. The two sections that +cannot be machine-derived — Highlights, and the upgrade checklist — say so where they stand +rather than being filled with a guess. -**How to use it.** Prune. Entries that describe an internal refactor, a gate, a test or a -docs fix belong in the changelogs and not on a release page. What survives the prune, grouped -and rewritten, is the release section. +**How to use it.** Prune. What survives, grouped and rewritten, is the release section. **Published 2026-09-09**, taking over from [17.3.0](/docs/releases/v17/17.3) as the current version of the v17 line. 396 changesets across 61 packages: 35 carrying a breaking marker, 140 other `minor` entries, 221 `patch` entries. -## Breaking changes & migration in 17.4.0 +## Highlights — 17.4.0 + +⛔ **Not written, and deliberately not guessed.** Every other release page opens with a short +list of what the release means for a reader — which of 396 changesets matter, and why. +That is the one section no mechanical pass can produce: it is a judgement about audience, not +a transformation of the entries below. Write it from the breaking list first. + +## What's new in 17.4.0 + +### Breaking changes & migration in 17.4.0 ⚠️ **Mechanically detected**, by a `!` in the entry's own conventional-commit prefix or the literal `BREAKING` in its opening paragraph. ⛔ Neither the presence nor the absence of that -marker was verified against the diff — an unmarked breaking change would not appear here. +marker was verified against the diff — an unmarked breaking change would not appear here, and +this list is therefore a floor, never a clearance. - `be75493` — **BREAKING** `os lint --generator` now refuses to run without `--eval`, instead of accepting the flag and ignoring it. `cli` @@ -68,7 +78,7 @@ marker was verified against the diff — an unmarked breaking change would not a `metadata`, `spec` - `65846bc` — a batch ROW reports a unique-constraint refusal as `UNIQUE_VIOLATION` — the same wire spelling as the whole-request failure on the same route (#14723) `metadata-protocol`, `objectql`, `rest` -- `8e0b297` — `positions[]` on the session payload is the SECURITY axis, not the better-auth […] scalar (#15136) *(one word elided from the changeset's own headline: ADR-0090 D3 reserved word)* +- `8e0b297` — `positions[]` on the session payload is the SECURITY axis, not the better-auth […] scalar *(one word elided: ADR-0090 D3 reserved word)* (#15136) `plugin-auth`, `spec` - `afa3a26` — a non-member schedule `timezone` no longer discards the cron expression, and a schedule already holding one stops instead of firing on a cadence nobody asked for (#16291) `plugin-reports` @@ -101,7 +111,7 @@ marker was verified against the diff — an unmarked breaking change would not a - `4f85e4d` — the record handed to a record-change flow no longer aliases the write's payload (#14744) `trigger-record-change` -## New capabilities in 17.4.0 +### New capabilities in 17.4.0 Every remaining `minor`-bumping changeset, verbatim. @@ -386,10 +396,10 @@ Every remaining `minor`-bumping changeset, verbatim. - `fe0d9a4` — `action.ai.requiresConfirmation` gets a real contract — a DECLARATION, not yet a live gate: the contract states that an AI-facing call on an action declaring the flag must carry an explicit confirmation and is refused without it, with a refusal that tells the caller how to retry. No door performs that refusal yet; setting the flag still stops nothing until the runtime half lands. `spec` -## Fixes in 17.4.0 +### Fixes in 17.4.0 -221 `patch` changesets, ⛔ not enumerated here — a patch entry is by construction not a -new capability, and listing all of them is what the per-package `CHANGELOG.md` files are for. +221 `patch` changesets, ⛔ not enumerated — a patch entry is by construction not a new +capability, and listing all of them is what the per-package `CHANGELOG.md` files are for. Distribution across the packages they touched: - `spec` — 50 @@ -415,6 +425,16 @@ Distribution across the packages they touched: …and 35 further packages with fewer entries. +### New in Console (Studio) — objectui pin `00d3f09c500c → 53ded82bf7a4` + +⚠️ **Endpoints measured, the chain between them is not.** 17.3.0 shipped with +`00d3f09c500c` (its own Console section names it as the last of that release's four moves); +`.objectui-sha` on this tree reads `53ded82bf7a4`, bumped by `30b09907` — *"the shipped console +lowers array analytics filters"* (#16788). ⛔ The intermediate bumps are not enumerated here: +this checkout's history for `.objectui-sha` does not carry all of them, and guessing the chain +would be worse than naming the gap. Whoever curates this page should walk the pin's real +history and list the moves the way [17.3.0](/docs/releases/v17/17.3#new-in-console-studio--objectui-pins-in-1730) does. + ## Upgrade checklist ⚠️ One checklist per release, for the release you are landing on **and** every release you @@ -422,7 +442,7 @@ cross to get there — and see [how far each list has actually been walked](/docs/releases/v17#upgrade-checklists). ⛔ **Not written.** An upgrade checklist is a walked route, not a restatement of the breaking -list above; the v17 page is explicit that a step nobody has run, presented beside steps that +list above; the v17 line is explicit that a step nobody has run, presented beside steps that were, is how a reader finishes a checklist and believes they are done. Nobody has walked 17.3.0 → 17.4.0. Until someone does, read [Breaking changes & migration in 17.4.0](#breaking-changes--migration-in-1740) directly and diff --git a/content/docs/releases/v17/index.mdx b/content/docs/releases/v17/index.mdx index 1104a2e3b7..167d06be9d 100644 --- a/content/docs/releases/v17/index.mdx +++ b/content/docs/releases/v17/index.mdx @@ -3,7 +3,6 @@ title: v17 description: "The v17 line — a truth-telling release. Files become owned records, the export privilege stops riding on read, the SDK is reconciled against the routes the server mounts, and a boot that cannot reach its datasource stops pretending it can. Per-release notes for 17.0.0 through 17.4.0." --- - **The v17 line** is a truth-telling release. Where v16 made *declared metadata* honest, v17 does the same for the surfaces around it: files stop being inline blobs and become owned `sys_file` records with a governed download path; the @@ -50,7 +49,6 @@ parsed-but-never-enforced spec clusters are removed rather than maintained. > the top and lists what it does and does not cover. Its upgrade checklist is > **not written**: nobody has walked 17.3.0 → 17.4.0. - ## Per-release notes Each release below is a self-contained page: what it changed, what breaks, and its own upgrade checklist. @@ -59,11 +57,10 @@ Each release below is a self-contained page: what it changed, what breaks, and i - **[17.3.0](/docs/releases/v17/17.3)** - **[17.2.0](/docs/releases/v17/17.2)** - **[17.1.0](/docs/releases/v17/17.1)** -- **[17.0.0](/docs/releases/v17/17.0)** — the major cut ([pre-release train](/docs/releases/v17/17.0-prerelease)) +- **[17.0.0](/docs/releases/v17/17.0)** — the major cut ([pre-release train](/docs/releases/v17/17.0/prerelease)) ## Upgrade checklists - One checklist per release, for the release you are landing on **and** every release you cross to get there. @@ -85,7 +82,6 @@ nobody has run, presented beside steps that were, is how a reader finishes a checklist and believes they are done. - Per-release checklists: [17.4.0](/docs/releases/v17/17.4#upgrade-checklist) · [17.3.0](/docs/releases/v17/17.3#upgrade-checklist) · [17.2.0](/docs/releases/v17/17.2#upgrade-checklist) · [17.1.0](/docs/releases/v17/17.1#upgrade-checklist) · [17.0.0](/docs/releases/v17/17.0#upgrade-checklist) ## References diff --git a/content/docs/releases/v17/meta.json b/content/docs/releases/v17/meta.json index 192d53c8b0..03b49276ab 100644 --- a/content/docs/releases/v17/meta.json +++ b/content/docs/releases/v17/meta.json @@ -6,7 +6,6 @@ "17.3", "17.2", "17.1", - "17.0", - "17.0-prerelease" + "17.0" ] } diff --git a/scripts/role-word-baseline.json b/scripts/role-word-baseline.json index 2461893872..2572a946c3 100644 --- a/scripts/role-word-baseline.json +++ b/scripts/role-word-baseline.json @@ -31,7 +31,7 @@ "content/docs/releases/v13.mdx": 15, "content/docs/releases/v14.mdx": 5, "content/docs/releases/v16.mdx": 5, - "content/docs/releases/v17/17.0.mdx": 2, + "content/docs/releases/v17/17.0/index.mdx": 2, "content/docs/ui/forms.mdx": 3, "skills/objectstack-ai/SKILL.md": 1, "skills/objectstack-automation/SKILL.md": 1, From ad03ed854142b4f7a017a9dbeae250fb2d3a5c2a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 08:37:39 +0000 Subject: [PATCH 3/5] docs(releases): delete the 17.0.0 pre-release archive instead of housing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rc.0 … rc.6 archive was 1,688 lines — 30% of the old monolithic v17 page — describing changes BETWEEN release candidates of a version that shipped on 2026-08-14. The train it records ended 2026-08-10. Everything it carried is contained in the shipped 17.0.0. It had no good home. Flat inside `v17/` it sat in the sidebar as a peer of the five real releases, which it is not. Nested under `17.0/` it forced a folder and a fourth path segment into existence for one archive page, and made 17.0.0 the only minor that expands. Both placements were wrong for the same reason: the page does not earn a place in the reading path, so any place given to it is one too many. Deleted. `17.0` goes back to a flat file, the folder and its `meta.json` go with it, and the sidebar loses the nesting level entirely: Releases > v17 > [v17, 17.4.0, 17.3.0, 17.2.0, 17.1.0, 17.0.0] Nothing is lost that a reader can reach for. "When exactly did X change during the rc train" is a changelog question, not a documentation-site one, and it is answered by the `CHANGELOG.md` files inside the published npm tarballs and by this repository's history. 17.0.0's own page now says that in place of the archive link, rather than pointing at a page that is gone. The two links into the archive — both written earlier in this same branch — go with it. `role-word-baseline.json` follows the file back to the flat path: `v17/17.0/index.mdx: 2` returns to `v17/17.0.mdx: 2`, count unchanged, nothing else re-baselined. check-release-notes, check-release-page-status, check-role-word, check-doc-anchors and check-docs-single-h1 all pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016M1gHPfToXY4sUdzsD2CHa --- .../releases/v17/{17.0/index.mdx => 17.0.mdx} | 6 +- content/docs/releases/v17/17.0/meta.json | 7 - content/docs/releases/v17/17.0/prerelease.mdx | 1688 ----------------- content/docs/releases/v17/index.mdx | 2 +- scripts/role-word-baseline.json | 2 +- 5 files changed, 6 insertions(+), 1699 deletions(-) rename content/docs/releases/v17/{17.0/index.mdx => 17.0.mdx} (99%) delete mode 100644 content/docs/releases/v17/17.0/meta.json delete mode 100644 content/docs/releases/v17/17.0/prerelease.mdx diff --git a/content/docs/releases/v17/17.0/index.mdx b/content/docs/releases/v17/17.0.mdx similarity index 99% rename from content/docs/releases/v17/17.0/index.mdx rename to content/docs/releases/v17/17.0.mdx index 6b1dd83345..4b9048dead 100644 --- a/content/docs/releases/v17/17.0/index.mdx +++ b/content/docs/releases/v17/17.0.mdx @@ -1579,8 +1579,10 @@ This section covers the window bundled at **`rc.0`**: `cf2d56e32a11 → 29→30 (dev) — relevant if you build the Console from source. -> The `17.0.0-rc.0` … `rc.6` train that preceded this cut is archived at -> **[17.0.0 pre-release train](/docs/releases/v17/17.0/prerelease)**. +> The `17.0.0-rc.0` … `rc.6` train that preceded this cut is ⛔ not reproduced +> here. Everything it carried is contained in the shipped 17.0.0 above; the +> step-by-step record lives in the `CHANGELOG.md` files inside the published +> npm tarballs and in this repository's history. ## Upgrade checklist diff --git a/content/docs/releases/v17/17.0/meta.json b/content/docs/releases/v17/17.0/meta.json deleted file mode 100644 index f4f2359068..0000000000 --- a/content/docs/releases/v17/17.0/meta.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "title": "17.0.0", - "pages": [ - "index", - "prerelease" - ] -} diff --git a/content/docs/releases/v17/17.0/prerelease.mdx b/content/docs/releases/v17/17.0/prerelease.mdx deleted file mode 100644 index e47143838e..0000000000 --- a/content/docs/releases/v17/17.0/prerelease.mdx +++ /dev/null @@ -1,1688 +0,0 @@ ---- -title: Pre-release train (rc.0 … rc.6) -description: "Archive: what landed between 17.0.0-rc.0 and rc.6, before the 17.0.0 GA cut. Kept for provenance; nobody upgrading between shipped versions needs this page." ---- - -Archive of the pre-GA train for 17.0.0. ⚠️ These sections describe changes **between release candidates**, all of which are contained in the shipped [17.0.0](/docs/releases/v17/17.0). If you are upgrading between published versions, this page is not for you. - -### Landed since 17.0.0-rc.0 - -These changes are on `main` after the `rc.0` cut and **roll into 17.0.0-rc.1** — -backend from the pending changesets (334 at the time of writing, 40 of them -`major`-class), Console from the objectui pin advancing -`4a4829d0ef39 → 7d9734d5e321` (79 commits, released as **objectui 17.1.0**). -Sourced from each repo's own changesets. - -One reading note: this page documents the whole 17.0.0 train and has been kept -current as the train moved, so the *largest* post-rc.0 landings are already -documented in place in the sections above rather than repeated here: - -- **ADR-0110 action-declaration admission** (#3935) — including its post-rc.0 - revision: the `OS_ALLOW_UNDECLARED_ACTIONS` valve was **removed before 17 - ships**; the refusal has no opt-out. -- **ADR-0104 D2 action params strict by default** (#3438) — the - `OS_ACTION_PARAMS_STRICT_ENABLED` opt-in era ends; - `OS_ALLOW_LAX_ACTION_PARAMS=1` is the escape hatch — plus the D1 evidence - gates (`os migrate value-shapes`, fresh-datastore attestation, released-file - collection behind the verified file migration). -- **ADR-0113** — `required` becomes the write contract; `storage.notNull` owns - the column. -- **ADR-0114** — the closed field-error catalog and the `fieldErrors` → - `fields` rename (#3977). -- **The #4001 unknown-key strictness campaign's later clicks** — permission - sets + flows, RLS / sharing rules / positions, approval configs, hooks + - datasources, and the app shell & navigation tree, all `.strict()` with - self-fixing errors. -- **The seven protocol-17 retirements** — `api.requireAuth` (#3963), the wait - `timeoutMs`/`onTimeout` pair (#4158), `query.joins`/`windowFunctions` - (#4286), the `fields[]` object form (#4196), `query.cursor` / `distinct` - (#4286), `BatchOptions.validateOnly` (#4052) — and #4350's correction - re-labelling them protocol **17** (their tombstones said "18", so - `os migrate meta` was stepping over the two stack conversions and the - generated upgrade guide omitted all seven). -- **The #4212 retirement family** — the plugin lifecycle hooks, the typed-event - cluster, `ObjectQLEngine.use()`, the Dev Mode Plugin Protocol (#4149). -- **The #3896 security sweep's enforcement half** — criteria-less sharing - rules share nothing, RLS `enabled` is enforced, RLS `priority` and the four - inert tool keys are removed. -- **Flow `errorHandling.maxRetries` has one default** (#4247), **per-run flow - summaries** (#4354), and **file-backed SQLite in WAL mode** (#3941). - -What follows is the rest of the window: first the metadata- and -protocol-facing changes an upgrading application developer must read, then the -platform capabilities an administrator gains, then the Console delta. - -#### Breaking / behavior — protocol & wire - -- **The actions route speaks HTTP (#3962, #3951, #3937, #3913).** The - accidental 200-with-inner-envelope double wrap is gone; the contract is now - identical to `/data`: ran-and-returned = **200** `{success: true, data: - }`, single wrap; ran-and-rejected (business rule / - validation) = **400** with `error.details.code: 'VALIDATION_FAILED' | - 'FLOW_FAILED'` and per-field `fields[]`; not-dispatched = a real 404/403/503 - (an unknown action used to come back `{"success":true,"data":{"success": - false}}`); crashed (`TypeError`, driver class, sandbox timeout) = **500**, - visible to gateway error rates and APM instead of arriving as a green 200. - Five defects traced to the extra layer, including the Console's green toast - on failed actions and `redirectUrl` never firing. Object-less actions - canonically key on `'global'` and the `POST /actions//:action` shape is - mounted. `client.actions.*` callers need no change — the SDK still never - throws, and folds every failure into `{success: false, error}`. -- **`POST /actions/:object/:action` dispatches on the declared `type` - (#3915).** A `flow` action executes via the automation service **as the - caller** — `userId` / positions / permissions / tenant forwarded, so a - `runAs: 'user'` flow enforces RLS as the invoker instead of the user-less - UNSCOPED path — and the params bag is seeded with `recordId` (+ the declared - `recordIdParam`); `url`/`modal`/`form`/`api` actions answer 400 naming what - to call instead of a registry miss. Standalone `defineAction` artifacts and - Studio-authored action rows now resolve on REST with their - `requiredPermissions` enforced (previously MCP-only). Script action bodies - run with a bound, attributed `ctx.api` — owner-scoped writes stop dying - `FORBIDDEN`, and body writes are stamped with the caller and tenant and - join the open transaction. -- **A list query either applies or fails (#4121, #4134, #4164, #4181, #4226, - #4254, #4256).** Every input of the list/read surface now either takes - effect or answers 400 — nothing is silently dropped: a `?filter=` that does - not parse (or parses to a non-filter) is `400 INVALID_FILTER` — it used to - return the **unfiltered** table as a clean 200; unknown fields in `sort` / - `select` / `expand` / `searchFields` / `groupBy` / `aggregations` are - `400 INVALID_SORT` / `INVALID_FIELD` (a typo'd `select` returned **every** - column against FLS and data minimisation; an unknown `groupBy` collapsed N - groups into one; `sum()` folded to `0` — indistinguishable from a real - zero in a report); an unknown bare query parameter is `400 INVALID_FIELD` - with the canonical suggestion (`?pageSize=5` used to become a zero-matching - filter answering `total: 0`); a dotted-path sort (`?sort=account.name`) is - rejected with the denormalisation prescription; explicit `filter` + bare - field parameters compose with `$and` instead of dropping the implicit half; - and two sort spellings that silently never applied — the SDK's declared - `orderBy: string[]` and the `{field: direction}` map — now actually sort. - Export honours the searched view (`search=` / `searchFields=`, #4181). -- **Unsorted paged reads are deterministic (#4363, #3821).** A `limit`/`offset` - read with no `orderBy` — the shape every list view without a configured sort - sends — is now ordered by the unique key on SQL and MongoDB, and any - non-empty `orderBy` gains a unique tie-breaker, so page 2 can no longer - repeat a row while another row is never served. **The background walks seek - instead of counting** for the same reason: seven scans paged with a growing - `offset` while writing to the rows they were reading, so rows slid past the - cursor unvisited — which meant `rebuildApproverIndex` **deleted** index rows - for requests it skipped (an approver silently dropped from someone's queue), - `verifyFileReferences` reported referenced files as unreferenced, the file - and pinyin backfills left rows unconverted under a run that reported - success, and `scanValueShapes` — which opens the value-shape migration gate - on its evidence — could vouch for rows it never read. -- **`SqlDriver.findOne(object, id)` is removed.** An undeclared bare-id branch - that was on no contract, that nothing outside the driver's own tests used, - and that the other two drivers answered differently. Pass a query. In the - same pass `bypassTenantAudit` becomes a **declared** `DriverOptions` member - (it was live but read through a cast, so no caller was type-checked), with - its limit stated: it silences a diagnostic and must not change which rows a - write touches. -- **Query options fold once, everywhere (#3795, #4346, #4371).** The five RPC - aliases (`filter`→`where`, `select`→`fields`, `sort`→`orderBy`, - `skip`→`offset`, `populate`→`expand`) resolve through one spec-owned fold - with one precedence — four of five pairs were inverted between readers, so - `?select=a&fields=b` answered `[a]` on one path and `[b]` on the other. The - fold now covers **every** engine method: `findOne` / `count` / `update` / - `delete` / `aggregate` with `{filter}` used to match **every row** — - `update(data, {filter, multi: true})` rewrote the whole table and - `delete({filter, multi: true})` emptied it. Conflicting spellings - (`{where: X, filter: Y}`, `{top: 1, limit: 3}`) are refused naming both; - direct engine callers passing wire-only spellings or unknown option keys now - get an error naming the canonical key instead of silence (#4371) — which - also fixed queue purge, which passed a key the engine never read and so - deleted nothing. -- **`query.having` is enforced (#4286 follow-up).** Declared since AST v2 and - executed by nothing, `having` now filters aggregated rows on both the - native-driver path and the in-memory fallback, with `$and`/`$or`/`$not` and - loud rejection of unknown operators. A query that carried it was silently - returning every group. -- **Request bodies validate at the entry (#3899, #3878).** Catalog-declared - `requestSchema`s are enforced with `400 VALIDATION_FAILED` + `fields[]`: a - garbage `POST /data/:object/query` body used to degrade into an unfiltered - full read, `POST /automation/:name/toggle {"enable": false}` used to - **enable** the flow and answer 200, and a misnamed `POST /notifications/read` - key was a 200 no-op. `/analytics/query` and `/analytics/sql` validate the - **bare `AnalyticsQuery`** at the entry — the `{cube, query, format}` - envelope dialect of the retired shim is tombstoned, and the filter field is - `where`, not `filters` — instead of dying downstream as a 500 SQL syntax - error. -- **Bulk writes are bounded and bound to the path (#3939, #3933, #3897, - #3946).** The declared 200-row batch cap is now real on all five bulk routes - (`400 BATCH_TOO_LARGE`, governed by `batch.maxBatchSize`, 1..1000); a body - `object` can no longer move a bulk write or a `/data/:object/query` read to - a different object than the gated URL object; `deleteMany` builds its - predicate from the validated id list only — caller `options.where` used to - widen a one-id request into deleting everything the caller could see — and - per-id deletes now run `deleteBehavior` cascades and RLS/FLS under the - caller (its response becomes the structured `BatchUpdateResponse`); and a - caller-supplied `context` is dropped at ingress on these paths (#3960 — it - could previously become the execution context, `{isSystem: true}` included, - where no server context resolved). -- **Response envelopes converge (#3843, #3983, #4038, #4053, #4224).** The - settings, datasource-admin, external-datasource, package and share-link - route modules emit the declared envelope — payload under `data`, errors as - structured `{code, message}` on the ADR-0112 ledger — which un-broke two - shipped SDK methods (`client.shareLinks.create()` / `.list()`); the - dispatcher's duplicate top-level payload keys are gone; `GET /ai/agents` - (the last unenveloped SDK route) conforms; `/api/settings/*` error extras - move under `error.details`, with `SETTINGS_VALIDATION.fields` becoming - ADR-0114 `FieldError[]`. Raw-`fetch` callers of these routes add one `.data` - hop and read `error.code`; SDK callers are shielded — and the client now - normalizes both server envelope families, so `err.code` is always the - semantic string, never the HTTP status number (#3918). -- **Field validation speaks the caller's language (#3957).** Built-in - field-validation messages render in the request's locale (en, zh-CN, ja-JP, - es-ES — `Accept-Language` / `?locale`, falling back to the workspace - locale), name the field by its translated label, and carry a structured - `constraint` (`min`/`max`/`actual`/…). Match on `code` and `constraint`, - not English message text; any built-in is overridable per deployment via - `validation.field.` translation keys. -- **An absent capability answers honestly (#4093, #4113, #3891, #4087, - ADR-0115).** The last fabricating fallbacks are deleted. A mounted domain - with no implementation answers **501 naming the package to install** - (`/automation`, `/notifications`, `/ui/*`, `/ai/*` — previously misleading - 404/503s that sent operators chasing phantom routing bugs). `/auth/*` - without an auth service answers 501 instead of a **200 carrying a fabricated - session for any email and any password** — the mock shipped in production - `@objectstack/runtime` and told clients the one thing a server must never - lie about. The degraded analytics shim — which dropped the caller's - ExecutionContext, so authenticated callers got **aggregates over rows RLS - would hide**, and read a non-contract `filters` key — is removed: without - `@objectstack/service-analytics` the routes are not mounted (404), and - discovery reports `analytics: unavailable` instead of hardcoding it always-on - (#3989, #4010). The dispatcher's dead `/storage` bridge (which could never - complete a request, and whose wildcard shadowed the real presigned - `service-storage` routes) is removed. DevPlugin's stub table is retired — - an empty slot in dev behaves exactly as in production, and the allow-all - permissions / no-row-filter RLS / unmasked-field dev fakes now **fail - closed** like production does. Discovery computes every slot's verdict from - the registered service's own `__serviceInfo` (the `_dev: true` marker is - retired), its remedy strings name real packages (ten of fifteen previously - named packages that do not exist), and a slot self-declaring - `handlerReady: false` is answered like an empty slot instead of having its - fabricated 200 served (#4058, #4089, #4130). -- **`HonoServerPlugin` is a transport adapter (#4073).** Its raw data-C+R - surface, its third (pre-`DiscoverySchema`) discovery payload and the - `registerStandardEndpoints` flag are deleted — data and discovery each have - exactly one owner (`@objectstack/rest`; the runtime dispatcher). Every - composed host (`os serve`, `objectstack dev`, cloud) already answered - byte-identically. The three current-user endpoints (`/auth/me/permissions`, - `/auth/me/localization`, `/me/apps`) register unconditionally — no longer - hostage to the flag — yield correctly around the auth wildcard in either - registration order (#4088, #4117), and resolve per request through the - multi-tenant `kernel-resolver` seam, so authenticated tenant callers on a - routing host stop being told `{authenticated: false}` (cloud#927). -- **The memory driver is pure in-memory again (#4065, #4083).** A bare - `new InMemoryDriver()` — and a declared `driver: 'memory'` datasource — - silently wrote `.objectstack/data/memory-driver.json` into the working - directory and reloaded it on the next boot; the `persistence: 'auto'` - default drifted from the accepted opt-in design (#815) and is now `false`. - Durability is explicit (`{ persistence: 'file' }`, per-datasource - destinations), and the driver's "production-ready" claim is trimmed: it - stores no constraints — use in-memory SQLite when constraints matter. - `os init` scaffolds stop naming `driver-memory` as a dependency. -- **The `artifact-api` metadata source is removed (#4246).** Zero consumers - existed and half its URL contract had been dead since v5.0. - `{ mode: 'local-file', path }` is the single artifact source (it accepts the - public/commit-pinned cloud URLs); a still-configured `artifact-api` throws - loudly at `start()` instead of silently falling through to filesystem - scanning. -- **Smaller wire corrections:** the plural `/meta/:type` spellings no longer - skip the book-audience / app-RBAC / dashboard gates the singular spellings - enforced (#3984); `book.audience: 'public'` genuinely serves anonymously on - secure deployments (#3963 step 2) — review books that declare it, they are - now really public; three orphan operator vocabularies with zero importers - leave the spec (objectui#2945 Track A); `DriverPlugin`'s inert - `{datasourceName, registerAsDefault}` options are retired (#4320); the - legacy `_dev: true` service marker is retired in favour of `__serviceInfo`; - and exported spec types stop resolving to `any` (`NavigationItem`, - `FormField`, `QueryAST['fields']`, the `z.input` sides of the recursive - schemas) — authoring code whose invalid shapes previously compiled clean - now fails `tsc` with the real error, gated by the new `check:exported-any` - (#4171, #4195, #4221). - -#### Breaking / behavior — metadata authoring & runtime - -- **Flow-node config is enforced end to end (#4277, #4045, #4027, #4347, - #4389).** The twelve contract-carrying builtins `parse()` their `config` - before executing — a violation refuses the node as a guard naming every - violated path; `registerFlow` **rejects** undeclared config keys (exact - path, declared key set, did-you-mean, per-key tombstones like - `screen.visibleIf` → `visibleWhen`) and warns on keys a `configSchema` does - not declare; declared bare-CEL slots are validated - (`screen.fields[].visibleWhen` authored as `'{var} == true'` shipped a - forever-paused run); and ADR-0031 regions (`loop.body`, - `parallel.branches[]`, `try_catch.try/catch`) are walked by conversions, - validators and lint alike, so nested nodes stop escaping every check. A - legacy `{var}` condition carrying an unresolved dotted reference now - refuses instead of comparing strings — `'oppRecord.amount > 500000'` was - constantly true: a gate that never gated. -- **Flow-node aliases graduate to conversions (ADR-0087 D2).** `subflow` and - `map` `config.flow` → `flowName` (#4278, #4045), `notify`'s nested - `source: {object, id}` → `sourceObject`/`sourceId`, and `connector_action`'s - mis-rooted config lifts onto `connectorConfig` — Studio-authored connector - nodes never dispatched; stored flows are healed at load. All protocol-17 - windows applied by `os migrate meta` and at every rehydration seam, - alongside the seven #3796 aliases documented above. -- **A blank hook target is refused.** `object: ''` / `[]` silently registered - the hook on **every object in the tenant**; a wildcard must now be the - visible `'*'`, and `bindHooksToEngine`'s `strict` option actually fails - fast. `defineHook()` lands so convention-scanned hook files get the same - parse-at-import treatment as the rest of the define family (#4269). -- **Stored metadata replays the conversion chain (#3903).** Studio- and - API-written rows at rest go through the same ADR-0087 machinery as authored - source at every read seam (including `registerFlow` rehydration) — a stored - action with the removed `execute` dispatches via `target` again; boot - hydration validates each row post-conversion and diagnoses invalid ones - with `[metadata_spec_invalid]` instead of shrugging. **The rows themselves - can now be brought forward too (#4327):** `os migrate meta --stored` - replays the chain over `sys_metadata` and rewrites what still carries a - pre-protocol shape, through the normal write path. Optional — the read path - is the guarantee either way — but it is what makes the conversion pass a - no-op on your data instead of a permanent shim. -- **Studio's metadata forms tell the truth (#3786).** Four of seventeen forms - had drifted from their schemas, so controls saved nothing: the Object → - Capabilities toggles bound a key the schema does not declare (all seven - dead — Track history, Searchable, API enabled, Files, Feeds, Activities, - Clone), and the Fields grid offered sixteen undeclared keys (PII, Encrypted, - Indexed, …) while missing the canonical spellings for five renames. Forms - are reconciled and gated registry-wide; re-author anything configured - through the dead controls. -- **Authored view filters compile (#3948, #4029).** Eight canonical - `VIEW_FILTER_OPERATORS` members (`equals`, `before`, `after`, …) that - validation accepted but the AST refused now lower to real queries on every - driver, and an uncompilable filter element **throws** instead of matching - every row. `saveMeta` persists canonical operator spellings, so the ~30-entry - legacy alias bridge stops growing and can eventually retire - (objectui#2945). -- **Analytics stops guessing (#4157, #4128, #4033).** An undeclared measure or - an out-of-vocabulary measure `type` **errors** instead of silently answering - `COUNT(*)` (revenue charts that were quietly row counts now say so); the - silently-dropped filter family — `$between`, `$null`, `$startsWith` / - `$endsWith`, inverted `$exists: false`, `$notContains`, and the `$or`/`$not` - combinators — now actually filters, so dashboards stop drawing every row; - time-bucketed queries project their bucket column (a trend chart got N - values and no x-axis). Time-typed dimensions gain chronological default - order, and `ReportSchema.order` becomes a declarable ordering lowered into - the dataset selection (#3916). -- **Temporal correctness lands as one campaign (ADR-0053).** `Field.datetime` - and `Field.time` get one canonical storage form per backend — mixed - epoch/ISO storage made a two-sided date window return **zero rows** on - SQLite while matches existed, and MySQL moves to `DATETIME(3)` with - UTC-pinned connections (REST datetime writes on MySQL previously failed - outright); legacy columns converge at schema sync and `os migrate plan` - lists the in-place work with row counts (#3912, #3994, #4047, #3954). A - bare `YYYY-MM-DD` upper bound covers its whole calendar day across SQL / - memory / MongoDB / the RLS write-side evaluator — dashboards lost the final - day's rows after midnight, and a `{ $lte: '{today}' }` RLS `check` policy - denied every write made after 00:00 (#3777, #4042). The type-blind - evaluators compare a JS `Date` against wire text correctly — fail-closed - RLS was denying legitimate SDK writes whose payload carried `new Date()` - (#4191). `date` `NOW()` defaults resolve UTC on every dialect (#4022). A - shared conformance matrix in `@objectstack/spec/data` is asserted by five - backends so none of these classes can silently re-drift. -- **Sharing authority is real authority (ADR-0111, #3902).** The - share-management surface authenticated the caller and then ran under - `SYSTEM_CTX` — **any signed-in user could revoke anyone's share, enumerate - who-sees-what, write self-grants, and define org-wide sharing rules**. Now: - `canManageShares` (system, record owner, Modify All Data, or hierarchy - write-scope depth) is enforced in the service on every - `/data/:object/:id/shares` verb; `listShares` is management-gated and the - open `sys_record_share` read surface is self-scoped for non-admins; the - whole `/sharing/rules` surface requires the new **`manage_sharing`** - capability (seeded into `admin_full_access`); an `edit`-level share no - longer confers **delete** (`canDelete` = ownership, write depth, or - `modifyAllRecords`; bulk deletes stop widening through shares); revoke - validates the share belongs to the URL's record; grants on objects the - sharing gates never consult fail `SHARING_NOT_ENABLED` instead of sitting - inert; and a record's owner or a Modify-All admin — not just the minter — - can revoke a share link. - -#### Security corrections - -Beyond the sharing-authority work and the #3896 enforcement half documented -above, four fail-opens were found and closed in this window — all ship in -rc.1, and administrators should know what changed: - -- **The project-membership gate never ran (#4127 sweep).** - `enforceProjectMembership` probed the auth service through a shape it never - had, so `userId` stayed unset and a **signed-in non-member passed the - gate** on every deployment with project scoping on. Found by the new typed - service-slot lint; the lint stays on to keep the class closed. -- **Analytics answered without a caller (#3891).** The degraded shim served - RLS-free, tenant-unscoped aggregates (removed — see above). -- **Dev stubs inverted their decisions (ADR-0115, #4093).** With - `plugin-security` absent, dev filled the slots with allow-all / no-RLS / - unmasked fakes behind one warn line; the slots now stay empty and fail - closed, and `plugin-dev` refuses `NODE_ENV=production` outright - (`OS_ALLOW_DEV_PLUGIN=1` overrides, and now brands the boot loudly instead - of starting silent, #3900). -- **Imported users skipped field coercion (#4251 sweep).** The - `/admin/import-users` route probed a method its service never had, so rows - reached `sys_user` uncoerced on every deployment. The same typed-slot - campaign fixed `handleAuth` reaching the (now deleted) mock instead of the - real auth service, and `/analytics/sql` crashing 500 on providers without - `generateSql`. - -#### New capabilities (backend) - -- **Approvals grow up as a decision surface.** A `decisionOutputs` entry can - declare `required: true` — an approve carrying a blank routing value is - refused before any write, instead of the next node faulting or the run - stalling (objectui#2955); a reassign writes structured `reassign_from` / - `reassign_to` parties that timelines render without free-text archaeology - (#4365); and a `runAs: 'system'` flow's writes are audited as - `svc:flow:` instead of "Unknown user" (#4366). That label is a - fallback, not a replacement for the operator — it stands in only for a run - that resolves no user at all (a schedule, or a system flow fired by a write - that itself carried no user). Where the trigger does resolve a user, #5494 - later on this page carries that user through unchanged, so the audit row - still names the human. -- **Run summaries learn honesty about side effects (#4395, #4396).** - `connector_action` and mutating `http` nodes report `unmeasuredEffect` (the - platform cannot know whether `crm.push_opportunity` writes), an - `unmeasured_count` column joins `sys_automation_run`, and the broken-sweep - detector becomes `selected > 0 AND acted = 0 AND unmeasured = 0` — without - the third clause it would fire on every healthy connector-driven flow. -- **Scheduled jobs actually fire.** Cron jobs registered before - `kernel:ready` landed on a placeholder adapter that silently ignores `cron` - schedules — in the default configuration a business plugin's cron jobs - never ran. Early registrations now migrate onto the DB-backed adapter, so - operators will observe scheduled work starting to happen. -- **`os migrate` keeps its promises (#3917, #3924, #3954, #3955, #3978).** - Zero database writes before the confirmation prompt (boot-time DDL is - deferred and rendered in the plan); a busy SQLite database is detected — - file-descriptor probe naming the holding pid, which works on the default - rollback-journal mode — and refused without `--force`; the plan lists - in-place datetime/time normalisation with row counts so large rewrites can - be scheduled into maintenance windows; zh-locale deployments stop being told - to drop their live pinyin `__search` companion columns as "destructive - orphans"; and virtual formula fields stop appearing as forever-pending work. -- **Boot tells operators the truth (#4012, #4085, #4095, #4110, #4096, #4167, - #4002).** Plugin boot-phase warnings survive the startup banner (previously - discarded wholesale — including the ADR-0110 action-governance inventory); - `os serve ` boots a fresh project with no compiled artifact instead - of dying with a misleading error, and grafts the config module's - `onEnable`/`functions` onto artifact boots so declared `script` actions stop - 404ing; a **named** artifact path that does not exist fails the boot naming - the path — it used to print "Server is ready" over an empty platform; - `OS_STORAGE_ROOT` actually takes effect (uploads land under - `.objectstack/data/uploads` from the first byte) and the spurious every-boot - "storage adapter swapped" warning fires only on a real move; `stack.storage` - — never a declared key, silently ignored — is linted with the two working - channels named (`OS_STORAGE_*`, Setup → Settings); and authored `api.*` - keys survive the serve-time config merge instead of being replaced - wholesale. -- **Platform infrastructure is composable (#4243, #4270, ADR-0116).** - `sys_migration` and `sys_secret` move to `PlatformObjectsPlugin`, so a - kernel without storage or settings keeps its migration ledger and its - encrypted-secret store — `Field.secret()` writes previously threw on - settings-less kernels. The kernel `Plugin` contract gains declared ordering - (`optionalDependencies`, `requiresServices`, `providesServices`) with - boot-time validation that names both plugins, both slots, and the fix; - `PLATFORM_ALWAYS_ON_CAPABILITIES` is a published export, so hosts stop - hand-mirroring the always-on slate (cloud's mirror had silently lost `sms`, - `messaging` and `analytics`). Graceful shutdown disconnects datasource - pools through the one datasource path, and adopted pools are never closed - by a kernel that does not own them (#3993). -- **Seeds load what you wrote (#3911, #3932).** Natural-key **arrays** resolve - for `multiple: true` lookups (previously dropped with a warn), and dropped - unusable references are counted and named in the boot banner - (`showcase 42 ok / 3 lost links`) instead of the load reading clean. -- **Authoring lints gather the evidence (#3786, #4120, #4271, #4254, #3991).** - The advisory `lintUnknownAuthoringKeys` reports every silently-stripped - object/field key across all sixteen metadata collections — with - rename/retirement guidance and nested-metadata descent — through - `defineStack`, `os validate` and `os compile`; error-severity - reference-integrity rules gate flow `update_record` / `create_record` - writes to undeclared fields and stale `searchableFields` declarations; - hook and action bodies get the same write-set analysis as advisories, plus - a runtime report for dead `ctx.record` writes (#4345); and a field carrying - both per-tenant `unique: true` and a global unique index is flagged before - the second tenant finds out. All three commands now answer identically by - construction — `os lint` and `os compile` were letting through react pages - and readonly flow writes that `os validate` rejected, including the field - resolution that **gates** (an unresolvable `` predicate - yields a silently empty list, indistinguishable from no data). Expect CI - running `os lint` to newly fail on metadata that was already broken. -- **Service slots are typed contracts (#4251, #4127).** `IObjectQLEngine` - lands (the seven consumer-local stand-in surfaces die), `getService` - resolves through a slot→contract ledger, and a shrinking baseline bans - `any`-typed lookups. This campaign is what surfaced the membership - fail-open, the import-coercion miss and the cron-adapter migration above — - the ratchet keeps the class closed. -- **Declared UI slots become authorable.** `FormSection.pane` places sections - in split forms explicitly (objectui#2153), and `element:button`'s - `properties.action` gains `InlineActionSchema` — previously authorable only - via `as any` (objectui#2997). -- **Package hygiene reaches consumers (#4261, #4248, #4097).** `CHANGELOG.md` - ships in every tarball again — 68 of 69 packages had silently severed the - delivery path for exactly the FROM→TO migration notes an upgrading agent - greps after a tombstone error; twenty packages stop shipping raw sources - and tests to npm; `os init` stamps `engines: { protocol: '^17' }` so new - packages join the ADR-0087 load-time handshake; and `spec-changes.json`, - the generated upgrade guide and the `spec_changes` MCP tool actually - report the 16 → 17 chain (they still said 16.0.0). - -#### New in Console — bundled objectui advanced `4a4829d0ef39 → 7d9734d5e321` - -143 objectui commits across five pin moves, released as **objectui 17.1.0**. -(The pin changesets enumerate 79 of them; one range under-enumerated its own -window and is recorded by `console-bebaebd39ace-backfill` — the fixes it -covers are folded into the list below rather than left to the changelog.) - -**How the Console pin is recorded on this page.** `scripts/bump-objectui.sh` -advances `.objectui-sha` whenever the bundled Console moves, and every move -writes a `.changeset/console-.md` that enumerates its own range. Each -move is reported under the *Landed since* window that shipped it — never -folded into an earlier window's range — so the chain reads end to end: -`4a4829d0ef39 → 7d9734d5e321` here (released as `rc.1`), `→ f5bc4c78be76` -under *rc.1* (`rc.2`), `→ f995a452d2ca` under *rc.2* (`rc.4`), -`→ 7dfbeb704e1e` under *rc.4* (`rc.5`), and `→ 0cf8f0f70d10` under *rc.5* -(`rc.6`). The two author-facing breaking migrations the bundle -carries (objectui#3233, objectui#3172) arrived with `f995a452d2ca` and are -documented under *Breaking changes & migration* above. - -- **The lockstep half of ADR-0110 — release-critical.** The rc.0 pin predates - the client fix, so the Console it built still posted `action.target` to - `/api/v1/actions/:object/:action`. Against a 17 server — which resolves the - declaration by `name` and refuses an unresolvable one — **every - target-bound script action would 404 from the shipped Console**. The - ADR-0066 D4 capability gate is now applied on every action surface, and a - modal action is client-side only (its server fall-through is dropped). -- **Action feedback stops lying.** A failed server action no longer shows the - green success toast, `redirectUrl` finally fires, one source now owns the - `/actions` envelope rule, and the Console reads the single-wrapped - responses the server started sending (#3962). A server rejection that names - fields marks **those** fields in the form; error-code branches survive the - ADR-0112 rename; a 400 from the server no longer reads to the user as - "check your connection"; and settings validation errors render against the - fields that caused them. -- **Filters, sorting and export agree with the server.** Every spec view - operator is bridged onto the filter AST (the client half of the operator - parity above); a view's own filter no longer vanishes when the user adds - one, nor arrives as a predicate on columns that do not exist, nor depends - on whether the query expands a lookup; a column-header sort orders the - whole list rather than the visible page, and a string `$orderby` reaches - the server as a sort instead of a list of character indices; sorting a - lookup column stops ordering by an invisible key; exporting a searched list - downloads the searched rows, not the unsearched superset; and every column - resolver reads one identity key, resolved at ingestion and reported out - loud. -- **Forms stop losing what you typed.** A tabbed or sectioned modal keeps - **every** tab's values and a split form keeps **both** panels' — previously - only the active pane survived the save; a `defaultValues` change no longer - discards the field being filled (and new defaults apply in the commit that - renders them); leaving a page with unsaved input in a modal or drawer form - is guarded; swapping `recordId` no longer leaves the previous record on - screen; and a wizard that ends on a field-less review step can finish. -- **Edits are safer.** Form edit saves send `If-Match` and surface 409 - conflicts instead of silently overwriting; a wizard with `allowSkip` no - longer submits past skipped fields; a select no longer wipes itself when - its value outruns its options; numeric and boolean option values survive - selection typed; multi-value lookup is selectable in inline edit and - hydrates through a batched `$in` (showing loading rather than the empty - placeholder); tabbed and split forms honour the form view's own `columns`. -- **Spec values render instead of failing three different ways.** Three tiers - of spec-declared values were silently wrong, validated into nothing, or - red-boxed the surface; they now render. The spec→FilterBuilder operator - table covers the whole view vocabulary, a spec `series[].type` draws (and a - spec-shape `series` plots at all), a chart says so when its rows carry no - category key instead of drawing an empty axis, a missing analytics - capability stops rendering as an empty KPI, a legacy string row action runs - instead of green-toasting a no-op, and a flow or action that failed under - HTTP 200 stops reporting success. -- **Bulk actions become per-record.** An object-declared bulk action runs over - the selection, `visible` is evaluated per selected record, params render - through the shared form-field widgets (lookup errors get Retry, `sys_user` - params get the PeoplePicker), and a bulk delete clears the row checkboxes. - The `bulkEnabled` derivation is dropped — the spec key is a tombstone. -- **Approvals and notifications.** Decision outputs reach both decision - surfaces; a reassign hand-off reads legibly and `svc:*` audit actors render - as "System"; the Console mounts the notification surfaces, each spec - `displayType` gets its own presentation, and the spec `icon`, config, - position and action variant are read instead of forked or ignored; - Attachments become a peer tab with a live count badge, translated. -- **Studio and the designers.** A page button created in Studio can be given - an action (the `InlineActionSchema` above is its contract); the script - node's form authors what the executor actually runs; a published - `configSchema` can no longer delete a node's sibling-block editors; and the - flow inspector preserves sibling blocks. `SplitForm` honours the new - `FormSection.pane`. -- **Types stop forking the spec.** `Page`/`App`/`Dashboard` validate the - spec's own fields instead of passing them through; navigation metadata - stops losing spec fields the renderer already honours; the `*Validation` - five and five more spec-named symbols are derived rather than copied; and - the form-field boundary between spec and runtime is declared, with a - tripwire against re-importing the wrong one. The SDUI public contract is - curated and guarded against silent drift, with declared inputs for the - `page:*` / `element:*` / `record:*` block families. -- **Resilience and chrome.** A transient `/me/permissions` or - `/me/localization` failure retries instead of stranding the app on its - loading state; a 403 is no longer blamed on the network; ⌘K search is no - longer capped at 8 objects; the chart view gets a label and an icon in the - view switcher; and the developer-voiced default form subtitle is dropped. - -### Landed since 17.0.0-rc.1 - -These changes are on `main` after the `rc.1` cut and **roll into 17.0.0-rc.2**. -At the time of writing the window had left 209 changesets pending — 46 -`major`-class, 74 `minor`, 55 `patch`, and 34 that release nothing (CI, tooling -and docs); the cut itself released **166 changesets — 45 `major`-class, 69 -`minor`, 52 `patch`**, counted across the `17.0.0-rc.2` sections of the package -changelogs. The Console pin stood at `7d9734d5e321` when the window opened and -advanced twice inside it — to `785b8a5d432c`, already the value when the -paragraphs below were written, and then to `f5bc4c78be76`. The two moves are -enumerated in `console-785b8a5d432c` and `console-f5bc4c78be76`; neither -carries an author-facing breaking migration, and the two that exist arrive in -the next window. - -Two campaigns dominate the window, and both are *finishing* rather than -starting: the #4535 dual-source convergence and #4001's close of the authorable -surface. Around them, the ADR-0049 enforce-or-remove sweep reaches the driver -and datasource contracts, ADR-0078 grows a completeness gate plus a runtime -twin, ADR-0119 makes multi-write atomicity real and migrations crash-safe, and a -run of security and fail-loud corrections lands. - -As in the rc.0 window, landings that belong to a section above are documented -**there** rather than repeated here: - -- **`findOne` must say which record it wants** (#4419) — the missing-predicate - read that returned an arbitrary first row. -- **The `workflow` service slot, `datasource.readReplicas`, the two connector - clusters and the `kernel` metadata-loader envelope family** — all in the dead - spec clusters table (#4451, #4468, #4480, #4499, #4411). -- **`MetadataWatchEvent.type` narrows to the three values the runtime emits** - (#4536), and **`MetadataExportOptions` / `MetadataImportOptions` move to - `./contracts`** (#4538) — both in the upgrade checklist. -- **`os migrate meta --stored`** (#4327) — in the checklist as the optional - stored-metadata pass. - -#### One name, one declaration — the #4535 dual-source campaign closes - -A bare name exported from two `@objectstack/spec` subpaths, resolving to **two -different declarations**, is the #4411 trap: an editor's auto-import picks by -name, the shapes overlap enough to compile, and the mistake surfaces far from -its cause — an `undefined` at an edge value, or (when neither side is -`.strict()`) every foreign key silently stripped. Seventeen clusters are closed -here, each judged individually against an import-statement-level scan of all -three repositories (framework, cloud, objectui): the declaration that actually -flows at runtime keeps the bare name. - -| Cluster | Name(s) | Resolution | -|---|---|---| -| C1 (#4572) | `WebhookConfig` / `WebhookEvent` | removed from `./api` — the bare names are `./integration`'s alone; `./api`'s OpenAPI webhook *descriptor* is renamed `OpenApiWebhookEvent` | -| C2 (#4587) | `MetadataEvent` / `MetadataBulkRegisterRequest` | removed from `./kernel` (a lifecycle envelope nothing emitted); `./api` owns them | -| C3 (#4610) | `Notification` / `NotificationConfig` | removed from `./ui` (a toast shape objectui never adopted) and `./system`; `./api` owns them | -| C4 (#4641) | `Session` | `./identity`'s declaration removed — the two did not even agree on field names, so a wrong pick failed as a runtime `undefined`; `./api` keeps the names | -| C5 (#4653) | `ActivationEventSchema` | converged on `./kernel`'s **structured** shape (`./studio`'s `z.string()` validated nothing); `./studio` re-exported it — then #4657 retired the whole vocabulary, see below | -| C6 (#4658) | `EventSchema` | `./automation`'s orphan state-machine *signal* declaration deleted (zero key overlap with the event-bus envelope); `./kernel` is the single source | -| C7 (#4741) | `PackageDependency` | `./kernel` renamed `ResolvedPackageDependencySchema` (the resolver's edge); `./cloud`'s manifest declaration keeps the bare name | -| C8 (#4661) | `RetryPolicy` | one declaration carrying the union of both; `./automation` and `./system` re-export it | -| C9 (#4684) | `RateLimitConfig` | `./integration` renamed `ConnectorRateLimitConfig` — the two limit **opposite directions** of traffic, and spell the window `windowSeconds` vs `windowMs` | -| C10 (#4740) | `EnvironmentArtifact` | single declaration on `./system` holding the **live wire** shape, re-exported by `./cloud`; the never-implemented v0 family (`functions` / `manifest` / `payloadRef` + 8 sub-schemas) removed | -| C11 (#4688) | `HttpRequest` | `./shared` holds the one declaration | -| C12 (#4703) | `FieldMapping` | **three** declarations → `ConnectorFieldMapping` and `ImportFieldMapping`; `./shared` keeps the base | -| C13 + C15 (#4738) | `DataSyncConfig` / `ConflictResolution` | the automation-side L1 "Simple Sync" layer (17 exports, zero consumers) deleted; `./integration`'s enum renamed `ConnectorConflictResolution`; `./ui`'s offline-sync vocabulary keeps the bare name | -| C14 (#4691) | `HttpMethod` | `./ui`'s 5-value subset renamed `HttpMethodType`; the value sets differed, not just the types | -| C16 (#4739) | `TenantPlan` + the tenant-provisioning family | `./system`'s never-implemented provisioning protocol deleted (including `IProvisioningService` / `ITenantRouter` / `ResolvedTenantContext`); `./cloud` untouched | -| C17 (#4737) | `ActionLocationSchema` | `./studio` renamed `ActionContributionLocationSchema` (3 Studio-shell values); `./ui`'s 7-value app-UI vocabulary keeps the bare name | - -Three more convergences land alongside them: the dual-source `MetadataFormat` / -`CacheStrategy` enums, which had diverged on their **values** (#4537); the -eleven `./contracts`-vs-domain parameter/result names (#4538); and the three -cross-form names, where the two sides did not share a *form* — `ShareRecipientType` -→ `RecordShareRecipientType`, plus `TransformType` and `suggestFieldType` (#4539). - -**None of this is authorable metadata**, so there is no `os migrate meta` step -and no tombstone: these are type and schema *exports*, and every break is a -compile-time `TS2305` naming the symbol. Fix it by changing an import path or a -name. The campaign leaves a gate behind — `dual-source-exports.baseline.json` is -now ratcheted by **symbol identity**, not by name (#4446), so two entries -exporting the same name from different declarations fail the build. - -#### The authorable surface closes — #4001 reaches zero - -The unknown-key strictness campaign began with a single object schema and ends -in this window. Every authorable type now rejects unknown keys with a -prescription that names the offending key and its canonical spelling: - -- **`ObjectSchema` closes on the PARSE path**, not only in `create()` (#4522) — - the founding example of #4001, which had been live only at one of its two doors. -- **`field`** (#4531), reusing the curated table that already recorded which - advice would have been wrong. -- **`dashboard`** header, filter bar and root (#4532). -- **`action`** (#4533), taking the ADR-0010 protection-envelope debt list to zero. -- **`mapping` / `agent` / `page`** (#4530) — and `strictObject` stops suggesting - keys that were removed. -- **`translation`** bundles and items, at both doors (#4529), retiring #3778's - bespoke ten-key guard into the shared error message. -- **The six `validation` variants**, each against its own key set (#4527). -- **Six more registered types** — `report`, `dataset`, `email_template`, - `skill`, `job`, `book` (#4528); `skill`'s silently-stripped `permissions` - stops pretending to be a gate. -- **The Studio authoring surface** — plugin manifests, the flow builder, the - object designer (#4541). -- **The view surface** — the container, both view kinds and the ~28 config - shapes under them (#4534). This is the last batch. - -`strictObject` (#4514) is what made the tail cheap: closing a shape is one call, -with `seed` and `doc` converted first. One gate turned out to be hollow on the -way through — the protection-envelope invariant test silently skipped 24 of 25 -registered types; fixed, it immediately found 8 undeclared envelopes (#4519). -The deleted-baseline discipline tightened too: a removed `authorable-surface.json` -line must now prove itself rather than being taken on trust (#4650). - -#### ADR-0049 enforce-or-remove — the sweep reaches the driver and datasource contracts - -The v16 sweep took the *authoring* surface; this one takes the **contracts**. -Each removal below was declared, strict-guarded or `required`, and read by -nothing. - -| Removed | Why | -|---|---| -| 31 of 34 `DriverCapabilities` bits (#4634) | written by every driver, consulted by no engine, planner, REST layer or renderer. With zero readers the values went wrong unnoticed: `SqlDriver` declared `streaming: false` while implementing `findStream`; `InMemoryDriver` declared `streaming: true` over a full-table read — the exact inverse of the guarantee. Three bits have a real reader and stay | -| `IDataDriver.findStream` (#4484) | a **required** contract method with no caller anywhere, documented as the memory-safe large read — and its two main implementations did the opposite | -| `IDataEngine.batch?` (#4618, ADR-0119 D3) | declared for the life of the contract, implemented by nothing, called by no one. Use `engine.transaction(cb)`, `batchData` with `options.atomic`, or `POST {basePath}/batch` | -| `datasource.retryPolicy` (#4583 B) | four keys read by no connect or query path — nothing ever retried on them. Do **not** "fix" this by renaming: `hook.retryPolicy` / `job.retryPolicy` are enforced, spell the delay `backoffMs`, and are a different key on a different type | -| `datasource.healthCheck` (#4583 C) | no probe loop ever existed. Liveness is probed on demand via the driver handle's `ping()` / `checkHealth()` | -| `datasource.capabilities` (#4583) | eleven booleans, none read — pushdown is decided by the runtime driver's own `supports.*`. `readOnly` is why this is not tidy-up: it reads as a safety property, the shipped CRM example labelled a datasource a "Read Replica" on the strength of it, and the datasource accepted writes exactly like the primary | -| `external.label` / `external.requirePermission` (#4583 D) | a second display name that never displayed, and a permission no authorization check ever consulted | -| `RestServerConfig.openApi31` (#4579) | `normalizeConfig` forwarded five keys and silently discarded this one; the served `/openapi.json` never consulted the config | -| `activationEvents` (both keys) + `ActivationEventSchema` (#4657) | lazy plugin activation no runtime in four repositories ever implemented — every plugin has always activated on load | -| The standalone `validation` metadata kind (#4509, ADR-0088) | `ValidationRuleSchema` carries **no object-binding key**, and all six variants are `strictObject`, so an author could not supply one. A rule authored through that door parsed, saved, reported success and intercepted nothing — including a `state_machine` rule, so an author could believe they had locked down state transitions and have changed nothing | -| `job` runtime creation and org overrides (#4509) | `JobSchema.handler` names a function in the compiled bundle's function table, which a runtime writer cannot reach — so a job created in Studio or via `PUT /meta` saved and never ran. `allowRuntimeCreate` / `allowOrgOverride` are now `false`; `job` stays first-class through `*.job.ts` and `defineStack({ jobs })` | -| Six `authorWarn` dead keys (#4667) | `book`/`group` `translations`, `job.id`, `translation.validationMessages`, `app.homePageId`, `app.areas[].order` — each shaped so an author reasonably concludes it configures something | -| Five unwarnable keys (#4509) | mapping `extractQuery` / `errorPolicy` / `batchSize`, contextSelector `includeAll` / `placement`. Four carry schema **defaults**, and a default materialises at parse time — so the advisory lint could not tell an authored value from a supplied one, and removal was the only channel that reaches the author | -| `app.areas[].visible` / `.requiredPermissions` (#4651) | not inert — **fail-open**. See Security corrections | -| `sys_comment.visibility` / `.reply_count` (#4756) | `visibility` is a security-looking key with no gate behind it: a comment marked `private` was visible to exactly the same people as a `public` one. After #4630 there is no replacement key — access is the record's | -| `DataEventType 'data.field.changed'` (#4673) | no producer, and unimplementable against `DataEventSchema` as written — the payload is record-shaped, with no `field` / `oldValue` / `newValue` slot | -| The orphan notification-template vocabulary (#4616) | `EmailTemplate` / `SMSTemplate` / `PushNotification` / `InAppNotification` existed only as members of the union #4610 deleted. Use `EmailTemplateDefinition` and its siblings | -| `script` node `actionType` branches (#4343) | see below | - -Two entries in the same batch went the **other** way, because a bridge existed: -`doc.tags` is now declared, so a book group's `include: { tag }` can finally -match something, and declared email templates reach the mail service (#4509). -Closing a door and building a bridge are the same discipline applied to -different evidence. - -One rename rounds it out: **`managedBy: 'system'` becomes `'system-data'`** -(#3355). ADR-0103 split the overloaded bucket additively in v16, which left the -surviving value naming the half that had already moved out — `system` sitting on -precisely the objects a user writes. `system-data` states both boundaries: the -schema is the platform's, the data is the admin's or the user's. One deliberate -consequence — the affordance default flips from LOCKED to **WRITABLE**, so the -`userActions: { create, edit, delete }` blocks the eight platform objects used to -re-open their writes with are now redundant and deleted. Keep `userActions` only -to *narrow*. - -#### Author-time gates: a completeness test, and a fourth door - -**ADR-0078 completeness (#4544).** An instance can be Zod-valid, use only live -properties, and have a correctly-authored sibling that provably runs — and still -be dead, because it omits a config its consumer needs and the consumer silently -no-ops. The founding case: an AI authored `{ type: 'summary' }` with no -`summaryOperations`, the index builder skipped it, the field read `0` forever, -and every gate the author could see was green. This is worse than the unknown-key -hole #4001 closed: there the author wrote a key we don't know and the parse -rejects it; here every key is one we know and the author gets a **success**. - -The judgement now lives in `@objectstack/spec/kernel` (`checkFieldCompleteness` / -`checkViewCompleteness`) and is consumed by `@objectstack/lint`'s -`validate-functional-completeness`, so `os build` / `os validate` / `os lint`, -MCP and hand authoring are all covered rather than only cloud's AI-build -graph-lint. Every rule cites the runtime line that makes it true: - -| Rule | The silent skip | Severity | -|---|---|---| -| `field/summary-without-operations` | `engine.ts` — `if (!d.summaryOperations) continue` | error | -| `field/formula-without-expression` | the formula plan is built only from fields that have one | error | -| `field/relationship-without-reference` | `$expand` — `if (!referenceObject) continue` | error | -| `field/choice-without-options` (`select`, `radio`) | `record-validator.ts` — an empty option list disables server-side value validation | error | -| `field/choice-without-options` (`checkboxes`) | same branch, shared with free-form | warning | -| `view/layout-without-binding` (`kanban`, `calendar`, `gantt`) | the renderer falls back to literal default field names | warning | - -The deliberate **non**-rules are pinned as hard as the rules: `multiselect` -without options is blessed by `record-validator.ts` as a mode, so flagging it -would be another false prescription. Phase 3 adds the shapes whose verification -passes are done — a webhook with no `triggers` (#4565), an action nobody placed -(#4501), and nav targets that are not object names (`page` / `report` / -`dashboard`, ADR-0072, #4574). Phase 4 gives the field rules and the webhook -rule a **runtime twin** (#4599): `SchemaRegistry.registerObject` — the choke -point every metadata door converges on — emits the *same rule ids* at -registration, one aggregated line per object, and **warns without ever -throwing**. An inert field must not kill a boot that thousands of healthy -objects share. - -**The fourth door (#4463).** The 26 author-time rules `os validate` / `os build` / -`os lint` share ran on those three commands and nowhere else — but every runtime -metadata write (Studio's designer, REST `/meta` CRUD, an MCP agent authoring a -flow) reaches `saveMetaItem`, which did a Zod `safeParse` and stopped. For a -tenant, that was not the weakest of four doors, it was the **only** door: a -`sys_metadata` overlay row is not in the CLI's config file, so there was no -command they could run instead. An approval flow whose `expression` approver is -broken CEL is Zod-valid, so it saved, registered, and failed at the node's entry -the first time it fired. - -- `AUTHORING_RULES` moved from `packages/cli` into `@objectstack/lint`, with a - kernel-safe `@objectstack/lint/runtime` subpath that loads neither `typescript` - nor `sucrase`. There is one table, and a second cannot be introduced without - failing `authoring-rule-wiring.test.ts`. -- A `state: 'active'` `saveMetaItem` — and the draft→active promotion in - `publishMetaItem` — of a **flow** runs the flow / approval / expression / - reference rule families and refuses a gating finding with **422 - `INVALID_METADATA`**, carrying `rule` / `path` / `where` / `message` / `hint`. -- **Draft saves are never gated.** Only the write is judged: rules run twice - (with and without the submitted item) so a pre-existing violation in a stored - row never blocks an unrelated save. -- Escape hatch `OS_ALLOW_UNLINTED_METADATA_WRITES=1` turns the refusal into a - loud log for a migration window. Only `flow` writes are gated in this pass; - every other type carries a recorded reason in the registry. - -Alongside them: every author-time rule that *can* gate now runs on all three -commands (#4409); view `searchableFields` is validated at build time against the -same judgement the runtime applies, so a `lookup` typo no longer waits for a 400 -(#4830); `has(x)` is not a null guard, and publish rejects unguarded nullable -comparisons (#4763); `bulkActionDefs` gets a real shape and its aggregate name is -linted (#4457); `validateFormLayout` is wired into the registry; and the liveness -gate now governs **every** registered metadata type (#4487, #4488). - -New CI gates land with them — `check:adr-anchors` so governed code keeps naming -its decision, expanded to the full authz/security ADR set (#4575); -`check:init-service-contract` (#4471, ADR-0116); a startup-registry-verdict gate -(#4777); a driver-conformance run that discovers zero drivers now **fails** -(#4646); `check:i18n` fails on an undeclared authoring key rather than only on -bundle drift (#4804); and `check:react-conformance` is renamed -`check:react-declaration-parity`, because it compares two declarations and said -it compared a declaration to an implementation (#4472). - -#### Atomicity and durable migrations — ADR-0118, ADR-0119 - -- **`atomic` becomes a guarantee (#4612, D1/D4).** `batchData`'s - `options.atomic` promised "rollback entire batch on any failure" and delivered - a `break`: every write before the failure stayed committed, and the response - reported those rows `success: true` under the one flag whose job is to - guarantee they were undone. An explicitly atomic batch now runs inside one - `engine.transaction()`; rows come back `ROLLED_BACK:` / `NOT_ATTEMPTED:` with - the causal error, and **no row reports success**. On a runtime that cannot roll - back the request is refused with **501 `NOT_IMPLEMENTED`** rather than - degrading silently. `IObjectQLEngine.transaction` joins the slot contract, so - plugin space reaches it without `as unknown as` casts. - **The declared default flips `true` → `false`** — aligned down to what every - site already did, rather than up to what none of them did. -- **Its two siblings follow (#4620).** `deleteManyData` was fake-atomic (a - partial delete with no natural undo, reported as atomic) and `updateManyData` - ignored `atomic` entirely. Both now run the same shared atomic runner, so a - fourth copy of transaction handling cannot drift into a fourth lie. -- **The migration journal (#4617, D2).** Migration-class work does not fit in - one transaction: a million-row backfill cannot hold a write lock, and a - process *killed* defeats in-process rollback entirely. `runMigrationJournal` - (`@objectstack/core`) preflights every step's read-only validator before any - step writes, chunks the rows, runs each chunk inside `engine.transaction()`, - compensates newest-first on failure, and on restart resumes forward from the - first chunk lacking `chunk_done` — or unwinds, per the plan's `onCrash` - policy. The invariant that carries it: `chunk_done(i)` is written **inside** - the chunk's own transaction and `chunk_started(i)` autonomously before it, so - `started ∧ ¬done` has exactly one meaning. `sys_migration_journal` is - registered unconditionally, because recovery must be discoverable with zero - host wiring. Boot reconciliation and `os migrate resume` make an interrupted - run impossible to miss. -- **Actors are typed, not stringly (#4608 ADR-0118, #4586).** A non-user actor - is represented as `NULL` with an explicit `isSystem`, fail-closed, and - better-auth's real operator is now carried onto identity-table writes so - `sys_member` history stops saying "system". `sys_metadata_history.recorded_by` - is declared `Field.lookup('sys_user')` and was being filled with the **string** - `'system'` — an id that dereferences to nothing under any reading; it stores - `NULL` now (#4556). -- **Stored metadata gets a finish line.** `os migrate meta --stored` rewrites - `sys_metadata` rows in place (#4327) and now covers flow rows too (#4454); - `POST /meta/_migrate-stored` runs it without a shell; `saveMetaItem` - canonicalizes flow bodies on write, so a Studio edit heals a legacy row like - every other type's (#4542), and says so out loud when it had to skip - canonicalization (#4580); `duplicatePackage` stops minting pre-protocol flow - rows (#4498). - -#### Protocol & wire changes - -- **Batch per-row results deliver their declared shape (#4793).** The rows of - `POST /data/:object/batch`, `/updateMany` and `/deleteMany` had drifted from - `BatchOperationResultSchema`: the schema, the SDK's exported - `BatchOperationResult` and the reference docs all said `errors: ApiError[]` / - `data` / `index`, while the wire carried `error: string` / `record` and never - sent `index`. A TypeScript consumer written against the published type - compiled, validated, and read `undefined` at runtime. A conformance pin now - parses every emitted row against the schema. `ROLLED_BACK` and `NOT_ATTEMPTED` - are registered batch-row error codes. -- **`IAutomationService.getSuspendedScreen(runId)` is async (#4515).** A - synchronous signature could only read the engine's in-memory hot cache, so - after a restart a still-suspended screen run could be *resumed* (200) while - `GET …/runs/:runId/screen` answered 404 — the refresh-safe re-fetch failing in - exactly the situation it exists for. It now falls through to the durable store - that `resume()` rehydrates from. One-line fix: `await` the call. -- **`sys_comment` derives its access from the record its thread names (#4630).** - See Security corrections. -- **The app-metadata gate filters inside `areas[]` (#4722).** Response shape - tightens: gated navigation items no longer appear at all, and an area filtered - empty is stripped. -- **Discovery stops advertising routes for kernel-internal slots (#4318)** — - cache, queue and job have no HTTP surface. -- **Metadata audit history and global search sort by `order`, not `direction` - (#4674)**, and the `/meta` read/write/delete boundary settles on one canonical - type key (#4432). -- **Four ADR-0112 envelope defects found in the v17 verification sweep** are - closed (#4431, #4435, #4436, #4483), along with the `$search` field set. -- **A stored reference value that is an embedded record is no longer a valid id - (#4455).** `ReferenceIdValueSchema` was `z.string().min(1)`, and in SQL a - legacy embedded reference reaches storage as JSON *text* — a non-empty string. - So `os migrate value-shapes`, the evidence half of the ADR-0104 D1 gate, ran - the scan on a deployment carrying exactly the values it exists to find, was - told it was clean, and closed the gate. - -#### Metadata authoring & runtime changes - -- **A hook `condition` the platform cannot evaluate now ABORTS the operation - (#4775).** It used to `logger.warn` and `return false` — the hook simply did - not fire. "The condition said no" and "the platform could not work out what the - condition says" carry **opposite** risks depending on the hook, and collapsing - them into one outcome meant a `before*` guard silently let writes through. - Hooks that have been getting by on that skip will now fail the write; that is - the point, not a side effect. -- **A hook `condition` reads the record, and can express a transition (#4770, - #4784).** The gate evaluated against `ctx.input.data` — only the fields the - write happened to carry — so `condition: "record.done == true"` did not run on - the most ordinary updates there are. It now evaluates against **stored ⊕ - payload**, total over the object's declared fields, and the CEL scope binds - `previous` alongside `record`, which both published skill docs already taught. - Write a transition as `previous.done != true && record.done == true`. -- **Validation rules fail CLOSED (#4649).** A `script` / `cross_field` / - `conditional` rule whose predicate faulted was logged at WARN and **skipped**, - so the write went through while the rule stayed declared and enforced nothing. - The merged record is now total on update as well as insert, and a predicate - that still cannot be evaluated rejects the write with `VALIDATION_FAILED`, - naming the rule and the key it read. `severity` still governs blocking. -- **A `script` node is a function call (#4343).** It had four ways to name what - it ran and only one ran anything: `actionType: 'email' | 'slack'` were - logger-backed stubs that reported success and delivered nothing, `template` / - `recipients` / `variables` fed those stubs, and an inline `config.script` was - recognized and never executed (there is no server-side JS sandbox). What - remains is what worked — `config.function` (now **required**), `config.inputs`, - `config.outputVariable`. `script` and `subflow` config is parsed at execute - time. -- **A `user` field carries its target in the TYPE (#4438).** `Field.user()` - writes `reference: 'sys_user'` itself, but two callers read `field.reference` - raw and disagreed, so `?expand=` answered - `400 INVALID_FIELD … declares no target object`. Metadata authored without the - redundant key — hand-written JSON, an AI author, a Studio form — was read as - under-specified when it was complete. `referenceTargetOf` is now the single - arbiter both halves of the expand path read. -- **A `datasourceMapping` rule is routing, not a hint (#4462).** Map an object to - a Postgres datasource with a bad URL and the boot succeeded, `/ready` answered - 200, the datasource name appeared in zero log lines, the write returned 201 — - and the row was physically in the **default** store. A mapping that matches and - names a datasource with no live driver now throws. -- **`datasource.config` is parsed against its driver's contract (#4410)**, and - the driver factory's four legacy `??` fallbacks graduate into an ADR-0087 - conversion instead of staying as consumer-side aliases (#4456). -- **Decision routing has one working model (#4414, #4440).** The two shapes that - could never route are gated at author time, the inert `config.condition` is - flagged, `isDefault` is enforced, and an unclaimable branch label stops being - swallowed. `evaluateCondition` decides its dialect from the source rather than - from the caller (#4336). -- **The `record:*` blocks are withdrawn from the react tier (#4413)** — no - renderer read the props they published. -- **Flow node-type validation waits for the plugin vocabulary to close (#4771)**, - so an approval flow is no longer mis-reported as "will fail at runtime". -- **`Seed.env` is enforced (#4836)** — environment-scoped datasets no longer seed - everywhere — and a seed replay keeps its per-org tenant stamp as an id rather - than resolving it as a natural key (#4644). -- **A `defaultValue` runtime token never becomes a column DEFAULT (#4560).** - -#### Security corrections in this window - -- **Comments were readable by anyone who could guess a thread id (#4630).** - Attachments derive visibility from the parent record; comments derived - nothing. On the same record, the same user who got `200, 0 rows` for the - opportunity and its attachments got `200, 1 row` for its comments — and could - `POST` a new one. `sys_comment` now derives its access from the record its - `thread_id` names. -- **Navigation gates inside an area were rendering-layer politeness (#4722).** - `filterAppForUser` is the server-authoritative visibility gate for app - metadata on `/meta`, but it only walked the app's **top-level** `navigation` - tree — `item.areas` was never read. `requiredPermissions` / `requiresService` - written on an item inside an area were enforced only by the client renderer, - so the item and its `objectName` / `pageName` / `componentRef` targets arrived - in the `/meta` body regardless. The same `filterNav` now runs on every - `areas[].navigation`, and the service-probe surface was widened to match — a - probe narrower than the filter would strip a live entry. Note the deliberate - asymmetry: `visible` (CEL) is still client-only at every level. **If it must - never reach the browser, write `requiredPermissions`, not `visible`.** -- **Two area-level gates failed open (#4651).** `app.areas[].visible` and - `.requiredPermissions` were not inert keys — an author wrote - `requiredPermissions: ['sales.admin']` on a navigation area, got a clean parse - and a stored value, and the area with everything under it was served and - rendered to **every user**. Both are removed, with a prescription; this is a - real migration, not a rename. -- **An unscoped multi-delete of `sys_attachment` is refused (#4757)** rather than - authorized. -- **`/analytics/query` is scoped to the caller's readable records, and a measure - over a missing field is refused (#4467, #4437).** -- **Deactivating or deleting a sharing rule now withdraws its materialised - grants (#4433, #4434)** — and `DELETE` stops answering 500. -- **The org-admin auto-grant can actually revoke (#4640)** — `tryDelete` was - calling the engine's `delete` with the wrong signature, so demoted admins kept - tenant admin. -- **Permission-set projection writes only spec-declared keys, and a failed - backfill becomes loud (#4669).** -- **AI routes carry the capability channel onto `req.user` (#4705)**, and a - degraded tenancy posture no longer hands out a default organization (#4423). -- **OTP hardening.** The per-number send budget counts in the shared store - rather than per node (#4790); the cooldown's retention follows the declared - value instead of a hard-coded hour (#4808); and the rate-limit counters - resolve the kernel cache lazily, which fixed both a spurious warning and the - functional hole that shared rate limiting never engaged (#4772). -- **The issuer is the provider's to declare (#4552)** — Google account links - stopped resolving under a synthesized one. -- **Approvals.** An admin override of a staffed approver slate is recorded *as* - an override (#4466); the record lock holds for predicate (`multi`) updates - (#4778); stranded requests nothing was looking at are findable (#4469); a - decision can no longer report success while its flow stays parked (#4420); and - `resume` enforces the suspended screen's declared field contract (#4477). -- **Three auth-gate disconnects close (#4509)** — `email_template` bridges to the - mail service, `job` and `validation` close their doors. -- **The ADR-0104 fresh-datastore attestation concludes on this boot's own data - (#4769)**, not on the emptiness it remembers from before the boot wrote. - -#### New backend capabilities in this window - -- **Predicate writes get an honest bulk event contract (#4639).** A `multi: true` - update or delete reaches `updateMany` / `deleteMany`, which resolve a row - **count** — satisfying neither `DataEvent.recordId` nor `before` / `after`, so - the engine used to fabricate an event with `recordId: ''`. New `BulkDataEvent` - (`data.records.updated` / `data.records.deleted` — note the plural) carries - `matched` and no `recordId`, so a consumer knows from the type alone what is - coming. Webhooks gain opt-in `bulk_update` / `bulk_delete` triggers — - deliberately **not** extra sources for the per-record ones — and the SDK gains - `client.events.subscribeBulkData`. A predicate matching zero rows publishes - nothing. -- **`subscribeData` and `subscribeMetadata` deliver what they declare** — real - `DataEvent`s (#4626) and real `MetadataEvent`s (#4602), with loud boundary - validation. -- **`@objectstack/client-react` grows bulk-write hooks, and `useAutoRefresh` - refreshes on predicate writes (#4678).** Five hooks stopped looping on - dependency identity (#4693, #4694), and the package finally has a test harness - pinning realtime behavior (#4682). -- **Blueprints can say what they compute.** A `formula` field states its - `expression` (#4577), and a blueprint can declare roll-ups, including - conditional ones (#4425). -- **Stored `lookup` references that resolve to nothing are reported (#4551)** — - an inspection that reports and never blocks. (It no longer misfires during - shutdown, where every `os migrate` subcommand was reporting `sys_metadata` and - `sys_view_definition` as unreadable — #4747.) -- **Aggregate bulk dispatch works end to end (#4461):** `_selectedIds` passes the - action param gate, so an aggregate bulk action makes one call instead of N. -- **A roll-up registered at runtime computes without a restart (#4427)**, and a - just-saved overlay is dispatchable immediately rather than after the next - listing (#4521). -- **Analytics correctness continues.** A percentage measure carries its SCALE, so - a ratio of 1 reads as 100% (#4442); a measure a query never reported reads `0` - for a count/sum on every merge seam, `compareTo` included (#4708); and - `compareTo` applies each measure's own filters, so `__compare` is the - same measure as the column beside it rather than a different one (#4820). -- **Boot and operations tell the truth.** A `sys_metadata` DDL failure is loud — - only "table already exists" may be silent (#4728); the history `event_seq` is - never invented from a read that failed (#4825); a best-effort degradation that - costs **durability** logs `error`, not `warn`, with a gate enforcing the rule - (#4632); declarative `defineJob` cron jobs are actually scheduled (#4567); and - the SQL outboxes stop writing `updated_at` on UPDATE, which was flooding the - `pnpm dev` console (#4765). -- **`os serve` distinguishes "the multi-org package is absent" from "the plugin - refused to mount" (#4818)**, and resolves the enterprise organizations package - from the **host app** rather than the framework (#4699, #4700). -- **A `RETURNING` write persists on `driver-sqlite-wasm` (#4518)**, unblocking - cold-boot e2e. - -### Landed since 17.0.0-rc.2 - -These changes are on `main` after the `rc.2` cut and **roll into 17.0.0-rc.4**. -Counted across the `17.0.0-rc.4` sections of the package changelogs, the cut -released **360 changesets — 44 `major`-class, 94 `minor`, 222 `patch`**, which -makes it the largest of the train's RC cuts (`rc.0` released 258, `rc.1` 262, -`rc.2` 166, `rc.5` 10). - -**Why there is no `rc.3` window section.** `17.0.0-rc.3` was tagged for every -package on 2026-08-03 by a `chore: version packages (rc)` run, but the round -consumed exactly one changeset — `BulkActionParamSchema`'s `options[]` entry -gaining passthrough (#4001) — and left every other package with nothing but an -*Updated dependencies* bump. Those `## 17.0.0-rc.3` changelog sections no -longer survive on `main`, and the Console pin did not move in that round. So -this one window section spans the `rc.2` cut through the `rc.4` cut rather than -splitting at a tag that records nothing. - -As in the windows above, landings that belong to a section higher up the page -are documented **there** rather than repeated here. - -- **Declarative `apis:` goes from silent no-op to live, in two steps.** The - surface was zero-execution end to end and said nothing about it: metadata - loading accepted `apis:` and `GET /api/v1/meta/api` returned every endpoint - with every key intact, while no route was ever mounted — so a declared path - died at Hono's `notFound` rather than at the dispatcher. It first learned to - **refuse loudly**, retiring the `ApiRegistry` family (#4936, #4939); the - blanket refusal then narrowed to per-endpoint publish gates and declared - endpoints **went live** (#5111, #5040 E7). -- **#4001 closes the authorable surface, batches 9–20.** Unknown keys are now - rejected on the flow-node config contracts (batch 9), `control-flow` and - `state-machine` (batch 10), nine automation shapes (batch 11), the ETL - authoring contracts (batch 12), the responsive/SDUI styling shapes (batch - 13), action-param options, public sharing, report sorting, the dataset - semantic layer and dashboard widgets (batch 14), all 14 `ui/theme` sites and - 5 of 7 `ui/chart` sites (batch 15), and `AriaProps` (batch 16). View - sub-blocks (batch 18) and object inner blocks (batch 20) stop dropping - unknown keys instead of reporting them. `ui/component.zod.ts`'s 29 sites were - judged **no gate** and deliberately left open (batch 17) — a recorded - verdict, not an omission. `ViewItemSchema` splits into an authoring schema - and a wire variant. -- **ADR-0049 enforce-or-remove reaches its widest sweep.** Retired in this - window: the dashboard widget action trio plus `aria`, and the build gate that - enforced a button nobody renders (#5010); `indexes[].type` and - `indexes[].partial`, two authorable index keys no driver ever read (#5248, - #4943); `connector.rateLimitConfig` and the whole outbound rate-limit shape, - whose engine never existed (#4911); `dashboard.widgets[].responsive` (#4876); - `HookContext.session`'s deprecated position key, declared and read by two - dead branches but never produced (#5050); `HttpServerConfigSchema` (#4938); - `NotificationActionSchema` and `EmbedConfigSchema` (#5015); nine theme token - groups emitted and read by nobody (#5021); the five `ui/` interaction config - modules (#4988); `IStorageService.list(prefix)` (#5540); the field-mapping - `transform` key and `FieldMappingTransform` (#5552); and the exported - `HttpServer` delegating wrapper. -- **The caller's positions converge on one spelling.** Action-body - `ctx.session` starts emitting the canonical `positions` alongside the - deprecated predecessor key it replaces (#5613); that predecessor is then - removed outright, leaving `ctx.user.positions` as the only spelling on - action bodies and AI route handlers (#6011). -- **A misconfigured provider fails the boot instead of degrading silently.** - `OS_EMAIL_PROVIDER=resend/postmark` without an API key no longer becomes the - log transport (#5132), and a misspelled `OS_SMS_PROVIDER` no longer does - either (#5713). -- **Wire shapes stop having two answers.** `GET /meta/:type/:name` answers - exactly one body shape; a sort node spelling its direction `direction` is a - 400 rather than a silently reversed page (#4721); `DeleteDataResult` declares - the schema it names — `success`, not `deleted` (#5638); and - `@objectstack/spec/openapi.json` stops describing routes it does not own, - shrinking to the contract half it does (#5744, #5588). -- **The driver contract stops writing the object name twice.** `IDataDriver`'s - query parameter becomes `DriverQuery` (#5181) — the first step of the - narrowing that finishes in the open window below. -- **Tenancy and durability corrections.** `organization/create` is judged - against the **effective** tenancy posture, so a deployment with no - organization wall can no longer create organizations (#5261); - `HierarchyScopeContext.organizationId` becomes the authoritative tenancy - field and is required (#5858); and a `sys_file` / `sys_upload_session` write - that never landed stops reporting success (#5216). -- **Two dialect convergences.** The retry policy's last two spellings meet - (#4964 `flow.errorHandling`, #4962), and `dashboard.widgets[].compareTo` - converges on the analytics executor's contract (#5011). - -#### New in Console — bundled objectui advanced `f5bc4c78be76 → f995a452d2ca` - -One pin move, released as **@objectstack/console 17.0.0-rc.4**: 98 non-merge -objectui commits, of which 64 of 65 changesets release; the enumeration is -`console-f995a452d2ca`. This is the move that carries the bundle's two -author-facing breaking migrations — **field widgets receive their metadata on -one key, `field`** (objectui#3233) and **flow node geometry is the spec's -`FlowNode.position`** (objectui#3172) — both documented under *Breaking changes -& migration* above. - -### Landed since 17.0.0-rc.4 - -These changes are on `main` after the `rc.4` cut and **roll into 17.0.0-rc.5**. -A short window: **10 changesets — 2 `major`-class, 4 `minor`, 4 `patch`**, -counted across the `17.0.0-rc.5` sections of the package changelogs. Nine are -listed below; the tenth is the Console pin move. - -- **`system-data` no longer grants CSV import by default** (#4671). - `managedBy: 'system-data'` narrows from `create/import/edit/delete/exportCsv` - to the same set with `import: false`, leaving `platform` as the only bucket - that grants `import` by default. Eight in-repo objects lose the import - wizard, three of them the RBAC join tables that decide who can do what. - Restore any one of them with `userActions: { import: true }` on the object — - only that verb, the rest still follow the bucket default. The authorization - boundary is unchanged: `import` is an affordance that decides whether a UI - entry renders, and every imported row still passes `DelegatedAdminGate`, RLS - and permission-set adjudication. What changes is leverage — one bad CSV is a - bulk grant. Recorded as the #4671 addendum to ADR-0103. -- **`subscribeMetadata`'s `type` narrows to `MetadataEventSubject`** (#4627) — - subscribing to an event the contract can never deliver is now a compile - error. -- **`engine.transaction` tightens, batch 1** (#5696) — `opts.require` is - fail-closed, and the handle carries an `owned` signal. -- **Transaction handles stop leaking across datasources** (#5351, #5696) — a - business write refuses loudly, and the system ledger moves out of the - transaction so it lands on its own. -- **A connector action can declare what it did upstream** (#4395), which is - what makes `connector_action` countable. -- **`XParsed` naming reaches every schema that has a parsed state** (ADR-0122, - #5551). -- **`os migrate summary-nulls`** (#6063) backfills roll-up count and sum - columns left `NULL` by pre-seed inserts. -- **`runAs: 'system'` `create_record` stamps all three ADR-0118 columns** - (#5494) — organization, owner and creator are non-`NULL`. -- **The strictness ledger gains a ninth verdict, `covered`** (#5249) — an - honest cell for a shape fragment with no gate and no parse whose every - consumer already guards. - -#### New in Console — bundled objectui advanced `f995a452d2ca → 7dfbeb704e1e` - -One pin move, released as **@objectstack/console 17.0.0-rc.5**: 28 non-merge -objectui commits, 9 of 9 changesets releasing, all `patch`; the enumeration is -`console-7dfbeb704e1e`. No author-facing breaking migration. - -### Landed since 17.0.0-rc.5 - -These changes are on `main` after the `rc.5` cut and **roll into -17.0.0-rc.6**, cut on 2026-08-10. The cut released **425 changesets — 36 -`major`-class, 123 `minor`, 266 `patch`**, and none that release nothing — -making it the largest round of the train, ahead of `rc.4`'s 360 (`rc.0` -released 258, `rc.1` 262, `rc.2` 166, `rc.5` 10). The window was still open -when the entries below were first written against 135 pending changesets; the -three bullets after them cover what landed between that draft and the cut. - -- **ADR-0122 phase 2 — the bare type name becomes the AUTHOR state.** 1384 - aliases flip and 102 `XInput` synonyms retire, completing the convention - whose `XParsed` half landed in the previous window (#5551). -- **The driver query contract finishes narrowing.** `DriverQuery` reaches - `aggregate` and `distinct` on memory and mongodb (#6212 batch C) and five - further drivers (#6075); `SqlDriver.distinct`'s third parameter becomes a - bare `FilterCondition`, so a spelling that silently returned the whole set no - longer compiles (#6320); `analyzeQuery` and `findWithWindowFunctions` stop - taking `any`, and the window door carries its own flat-shape types (#6212 - batches A and E); and the two undeclared `aggregate` / `func` aliases retire - (#6212 batch B, #6321). -- **ADR-0049 enforce-or-remove continues.** The L2 ETL layer retires — - `automation/etl.zod.ts` had no executor while the sync architecture document - was still recommending it. So do `system/http-server.zod.ts`'s runtime - vocabulary; the widget-registration vocabulary and five doorless i18n shapes, - with `FieldWidgetProps` deliberately **kept**; `ViewProtocol`'s five - viewId-addressed methods and their ten schemas (#6239); and `array_agg` / - `string_agg` from `AggregationFunction`, with `count_distinct` deliberately - kept (#6188). The SDUI component props are reconciled against the renderers - that serve them — 4 keys retired, 9 declared (#5775). -- **`composeStacks` stops last-wins on `i18n`** (#5051) — the same value - passes, a genuine conflict errors with a prescription. -- **`HierarchyScopeContext` carries the tenancy posture** (#6139), so a - single-posture `DEPTH` scope is legal rather than rejected. -- **`HookContext.api` narrows from `z.unknown()` to `IScopedContext`** (#5945) - — the first hook the documentation teaches finally compiles. -- **A `view` body must be a view before the union judges it** (#5599), and an - action param's `options[]` can speak a per-option `visibleWhen` (#5016). -- **One driver vocabulary, and `mongo` becomes `mongodb`** (#6345). `spec` has - owned the driver alias table since #4410 so that the id selecting a *driver* - and the id selecting that driver's *config contract* cannot disagree — but - the table never reached the two boot hosts, so `OS_DATABASE_DRIVER=pg` was - accepted by `os start` and **refused by name** by `os migrate`, and `libsql` - the same. Both now read the one table, `DRIVER_CATALOG` publishes `mongodb`, - `turso` gains a config contract, and the datasource factory can no longer - fall through to `memory` when it fails to recognise a name. -- **Doors that were open only by omission close.** A paused node whose - descriptor never declared `resumeAuthority` is now fail-closed — the generic - resume route refuses it with 403 until the descriptor opts in with `'any'` - (#5561; zero in-repo executors affected). `api` becomes code-only: the - runtime create door the endpoint matcher could never read answers 403 - `NOT_CREATABLE`, and the `**/*.api.ts` artifact route is untouched (#5488). - `registerHook` refuses an empty object target, and `engine.find` refuses a - formula field in `orderBy` rather than sorting on nothing. -- **The ADR-0045 publish gate gets its own machine-managed key** (#4829), so - `app.hidden` goes back to meaning navigation — and the built-in Account app - stops 404ing for every normal user. Nothing to rewrite by hand: stored rows - carrying `hidden: true` convert to `_unpublished: true` on read, and in place - via `os migrate meta --stored --apply`. -- **More enforce-or-remove, on the surfaces authors actually type.** - `GET /api/v1/notifications` loses the `cursor` half it honoured on neither - side of its contract — a caller paginating by the published shape re-read the - first window forever, both pages parsing green (#6361). `ExportFieldMeta` - sheds eight constraint keys (#6536); `ImportRequest.runAutomations` declares - the default the import route actually applies (#6704); `AggregationNode.distinct` - retires (one face honoured it, five ignored it); `ActionDescriptor.isAsync` - goes as a second spelling of `supportsPause` (#6748); `global_nav` leaves - `ACTION_LOCATIONS`; `manifest.loading` leaves the plugin contract; three - zero-reader SDUI page-component props retire and four more are declared, with - `page:tabs.type` renamed `tabStyle` (#5775, #6776). - -#### New in Console — bundled objectui advanced `7dfbeb704e1e → 0cf8f0f70d10` - -One pin move, released as **@objectstack/console 17.0.0-rc.6**: 66 non-merge -objectui commits, 24 of 24 changesets releasing; the enumeration is -`console-0cf8f0f70d10`. Two entries carry the author's own breaking -annotation, but both are TypeScript -export-surface changes inside objectui's own npm packages — the `GestureType` / -`GestureConfig` rename (objectui#3363), and objectui tracking the -`@objectstack` family at `17.0.0-rc.5` (objectui#3560). `@objectstack/console` -ships a frozen SPA build at the pin SHA and does not forward those type -entrypoints, so **no new metadata migration is registered for this move** — the -changeset records that verdict against ADR-0087. The same changeset also lists, -by subject rather than by count, the 42 commits in the range that carried no -changeset of their own. - -### Landed since 17.0.0-rc.6 - -These changes are on `main` after the `rc.6` cut and **ship in 17.0.0** — the -last window of the train, released by the cut that exited Changesets pre-mode -and published to `latest` on 2026-08-14. The cut released **375 changesets — 11 -`major`-class, 99 `minor`, 265 `patch`**, and none that release nothing, making -it the train's second-largest round behind `rc.6`'s 425 and ahead of `rc.4`'s -360. The window was still open when the entries below were first written -against 374 pending changesets; the one landing between that draft and the cut -is called out under *Metadata, packages & authoring gates*. - -As in the windows above, landings that belong to a section higher up the page -are documented **there** rather than repeated here. - -#### Credentials stop being writable in the clear (#7990, #8082, #8336, #8075) - -The largest coherent story in this window. `sys_metadata.metadata` is served -back by the ordinary data API and a datasource or connector artefact is -persisted whole — so every schema that *accepted* an inline credential was -storing that credential in cleartext at rest. Four doors close, in the order -authors were pushed through them: - -- **The credential keys.** Driver `config.password` (SQL/mongo) and - `config.authToken` (turso) are declared-unwritable: writing one fails `tsc` - (the input type is `never`) and fails the parse with a prescription naming - the replacement. The former alias spellings (`passwd`, `pwd`, `token`, `jwt`, - `auth_token`, `authtoken`) carry the same refusal. On the connector side, - `DeclarativeConnectorEntrySchema` now refuses a non-`none` `authentication` - on **every** authored entry, catalog descriptors included — previously only - provider-bound instances were covered (ADR-0097 §3), so a descriptor could - publish an inline `token` / `key` / `password` / `clientSecret`. -- **The URL that carried the same secret one syntax over.** `config.url` - accepted `postgresql://user:password@host/db`, which landed in `sys_metadata` - cleartext exactly as `config.password` did — and the key refusal itself - steered authors (very often AI authors) into the URL form. A shared - value-level parse now refuses a userinfo segment carrying a non-empty - password across the four URL-bearing driver schemas. A bare username - (`user@host`) stays accepted, so an untouched "Save" on a legacy row still - works, and runtime-environment DSNs (`OS_DATABASE_URL` and friends) are - unaffected by construction — they never pass through the authoring schema. -- **The placeholder escape hatch.** `${…}` syntax in connection-material driver - config keys is refused at publish (#8336). Placeholders in authored metadata - are resolved by nothing and reach the database client verbatim — measured. -- **The two dead families whose only distinctive feature was a credential - sink.** `ExternalDataSource` / `ExternalFieldMapping` / `ExternalLookup` and - `MessageQueueConfig` / `TopicConfig` / `ConsumerConfig` / `DeadLetterQueue` - are removed outright — 8 definitions, 22 exported names — with zero consumers - repo-wide and inline-credential sinks in both (#8075). - -There is deliberately **no automatic rewrite**: moving a cleartext credential -into `sys_secret` requires encrypting it through a running secret binder, which -a source transform cannot do, and auto-deleting the key would silently drop a -live credential. `os migrate meta` surfaces both changes as structured TODOs. -Setup → Datasources already does the right thing — its masked secret field -encrypts into `sys_secret` and writes `external.credentialsRef`; it never wrote -`config`. To make that route declarable, `DatasourceSchema` now accepts -`external.credentialsRef` — and only it — on `schemaMode: 'managed'` (#8588). - -The same sweep hardens what was already stored. The OIDC SSO `clientSecret` is -encrypted at rest; webhook custom `headers` are encrypted rather than riding -`definition_json` in cleartext (#7986); the subscriber's HMAC signing secret is -no longer readable from `sys_webhook` over the data API (#7799) and stops being -persisted on every delivery row (#7722); `sys_scim_provider.scim_token` and -`sys_oauth_application.client_secret` get their posture pinned; the datasource -read path stops serving stored credentials in cleartext; and -`sys_http_delivery`'s callout credentials leave the generic data-API read -(#8118). Two new `internal: true` fields — `sys_api_key.key` (#7728) and -`sys_session.token` — never return on the generic data path at all, and the -aggregate guard refuses `internal` columns rather than only the -`secret`/`password` *types* (#7922). Finally, the credential write door refuses -`""` (#8616): a secret field could be set to an empty credential that every -read then reported as masked. - -**Settings fail closed.** A write that would persist a secret through the -base64 `NoopCryptoAdapter` is refused rather than stored (matching the engine's -posture), encrypted values are redacted at the REST read boundary (#7522), -rotation actually repoints the secret handle and destroys the retired -ciphertext, and the refusal gets a wire spelling — `SETTINGS_CRYPTO_UNAVAILABLE` -— so a client can branch on it (#8273). A webhook holding an encrypted signing -secret re-arms the moment the `CryptoProvider` registers instead of ~60s later -(#8022); one whose stored secret or header map cannot be recovered **parks** -the subscription instead of arming it and delivering unsigned or with the -headers missing (#8542, #8558). - -#### Driver text stops reaching the caller - -A caught driver error was being interpolated into client-facing messages across -the metadata plane, which discloses dialect, schema and sometimes data. The -whole class is closed: the generic message path (#8136), the batch verbs' -`failed[]` `message` and `code` (#8333, #8441), the seed loader's -`errors[].message` (#8442), the package-publish door's `seedApplied` (#8443), -the direct-mount package door's leaky 5xx (#8086), a bulk write's per-row -`errors[].message` (#8502), and the shipped Postgres and bare-SQLite phrasings -of a driver failure. A package publish that faults in the driver is answered as -a server error rather than passing the text through. - -#### Breaking changes since rc.6 - -Beyond the credential doors above: - -- **`view.exportOptions` adopts the object form the renderer actually reads** - (#8010). `ListViewSchema` typed it as a bare format array while the only - renderer reads an object (`exportOptions.formats`, plus `maxRecords`, - `includeHeaders`, `fileNamePrefix`, `streaming`). A project following the - published type wrote `exportOptions: ['xlsx']`, the renderer saw - `.formats === undefined` and fell back to `['csv','json']` — so **no - declaration was both type-legal and functional**, reported from a live - customer project. The same ruling removes `'pdf'` from the format enum: PDF - export was declined platform-side (#1301), so the member was - declared-but-unrenderable. -- **Three pass-through-only list-view keys retire** — `striped`, `bordered`, - `virtualScroll` (#7176). The liveness ledger graded them `live`, but the - citations were forwarding copies, not appliers: the chain ends at - `ObjectGrid.tsx` with zero occurrences of any of the three. Delete the keys — - the grid frame is the renderer's own constant, and large datasets page via - the view's `pagination` block. -- **`field` loses `allowRuntimeCreate`** (#7893). A standalone - `PUT /meta/field/{object}.{name}` answered `200 {"success":true}`, persisted a - row, and the field **never** appeared in the object's `fields`. It now answers - 403 `NOT_CREATABLE` naming the remedy. Adding a field at runtime is not lost: - `object` keeps `allowRuntimeCreate: true`, so write the whole object — what is - withdrawn is a second, broken spelling of the same operation. -- **`engine.update()` loses `upsert`** (#8057) — declared on both update-options - schemas and on the engine's allowlist, read by no engine or driver path. A - caller passing `{ upsert: true }` got silence: not a refusal, not an upsert. -- **`/api/v1/auth/config` stops advertising `passkeys` / `magicLink`** (#7481). - Both flags were served from introduction and read by no client, so the payload - advertised two sign-in methods no user could reach. `AuthPluginConfig.plugins.*` - is unchanged — this narrows the served payload, not the server configuration. -- **`FieldReferenceSchema` leaves the list comparand positions** (#7596). Both - `$between` endpoints carried it and `$in`/`$nin` were `z.array(z.any())`, - which admits a reference — a form no backend has ever implemented. The - in-memory evaluator reads `$field` only off a non-array object, so `$in` - compared the reference *object* against stored values and never matched. -- **`submitBehavior.url` is ruled and enforced** (#7496) — relative-only, - declared-field interpolation, URL-escaped. -- **Cross-tenant uninstall must be declared** (#7780). `deletePackage` selected - rows with `{ package_id }` and added an organization predicate only if the - caller supplied one, so an omitted argument matched **every** organization's - rows — measured during #7705 at 5 of 5 deleted, including a foreign - organization's. A call naming neither an organization nor `allTenants` is now - a 400. -- **The memory driver declares itself single-tenant and refuses to boot - multi-tenant** (#6915). It implements no row-level tenant isolation — it never - reads `DriverOptions.tenantId` — so a multi-tenant deployment backed by it did - not fail; it served cross-tenant reads, updates and deletes silently. -- **`$contains` is case-SENSITIVE on MongoDB and the memory driver** (#6682). - The hardcoded `$options: 'i'` beside the `$regex` is gone from all four arms, - so `{ name: { $contains: 'acme' } }` no longer returns `ACME Corp`. This is - the last driver arriving at #4706 Q2 = A, after #6518 flipped the SQL family. - Both directions of the defect mattered: the fold over-matched — on an RLS read - scope that is over-reach, not merely a loose filter — and it folded the whole - Unicode range. -- **Closed query-parameter sets become REST ingress policy** (#7606), starting - with the first tier of data read routes. Handlers read the keys they knew and - ignored the rest, so a misspelled or invented parameter was silently dropped - and the caller got a plausible `200`. It failed in both directions: a dropped - `?objects=` fans a search across every object and a dropped `?fields=` - returns the whole record, while a dropped key inside a filter answers `200` - with zero rows — each shaped exactly like a legitimate result. Tolerated - traffic breaks here, deliberately. -- **A number field's declared `scale` is enforced — by rejection, never - rounding** (#7501). `scale: 0` accepted `11.5` and stored it verbatim through - both REST create and CSV import. It now answers 400 `VALIDATION_FAILED` with - field code `max_scale`; silent rounding would be silently altering data. -- **Two `sys_audit_log` action values retire** — `export` / `permission_change`, - then `restore` (#8315, #7675) — declared actions with no writer anywhere. - -#### Security corrections since rc.6 - -- **A tenant-scoped write with no active organization is refused**, naming what - is missing (ADR-0123 D2, #8247/#8208) — and a user's first session no longer - predates their membership, so its audit rows carry a tenant (#8245). -- **Uniqueness becomes per-organization wherever it was tenant-scoped.** A - declared unique index that ignored the tenant wall let one organization's row - block another's: `sys_setting` (#8555), `sys_position.name` (#8468), - `sys_user_preference` and `sys_capability` (#8323), five more across - plugin-security / plugin-sharing / plugin-webhooks / platform-objects / - service-messaging (#8554), and two beyond those (#8577). The tenant-scope - index is now declared whenever an object carries `organization_id` — whether - the platform provisioned that column or the author declared it (#8459) — and - `GET /meta/object/:name` serves it (#8375). -- **Sharing and invitation reads get their organization predicate.** A - `manage_sharing` holder with no active organization no longer reads every - tenant's sharing rules (#8158); `getRule`'s by-id branch is scoped to the - caller's organization (#7761); deleting a platform-global sharing rule is - refused to org-scoped callers (#7795); and a plain member can no longer read - the organization's whole invitation ledger (#8095), while a - `delegated_admin` can now read the invitations it issued (#8240). -- **`$expand` no longer discloses records the caller is 403'd from** — the - #2850 expand waiver is removed (#7626). -- **Federated objects stop being scoped by columns they do not have.** The - tenant wall no longer scopes a federated object by a phantom anchor (#7835), - the org-scope predicate is not injected onto external objects (#7738), and - minting a share row on a federated object whose `owner_id` is the platform's - injected anchor is refused (#8119). -- **Two RLS write-side holes close.** A `check`-only write policy no longer - disables both write-side row gates — a caller who could not read a record - could write it by id — and a by-id write target must be inside the caller's - readable set when only select-scope RLS is authored. Permission pre-image - probe faults now propagate instead of reading as absent rows (#7505). -- **`public_read_write` means what it says** (#8023): the object is writable by - everyone the access matrix grants `edit`, not only by each row's creator. - An app-declared permission baseline **composes** with the platform - `member_default` instead of replacing it (#7555), applied additively on - `/auth/me/permissions` and `/me/apps` (#7608). -- **Enumeration oracles closed.** `DELETE /reports/:id` and - `DELETE /reports/schedules/:scheduleId` stop telling a caller whether an id - exists; `GET /meta/app/` reports a permission denial instead of absence. -- **`403 PERMISSION_DENIED` stops handing business users internal authorization - vocabulary** on the object CRUD gate and the row-level/capability refusals, - and the dispatcher stops answering a denial's internal payload (#7450). - `controlled_by_parent` write refusals split by true semantics — three of the - six legs stop answering `PERMISSION_DENIED`. -- **Anonymous deny is consulted before the capability answer** on `/ai/**` - (#7653) and `/security` (#7911). -- **Impersonation takes effect for bearer clients** (#4467/#8049 lane work) — - the caller's token is rotated and `stop-impersonating` recovers the admin — - and `/auth/change-password` clears the force-change flag and enforces - password-reuse on the bearer lane, not only on cookies. -- **A member's stored membership grade is canonicalised at the write** (#8317), - so an org admin can no longer remove an owner; a member can revoke their - **own** API key (#8053); and `member_default` grants owner-scoped read on the - personal inbox (#7344). - -#### Protocol & wire changes since rc.6 - -- **`like` / `ilike` stop being folded onto `$contains` at the wire** (#7536), - and `$search` compiles to `$icontains` so textual search is actually - case-insensitive. -- **A `where` on a virtual `formula` field is refused, not answered with zero - rows** (#8296) — as is an unknown field inside `where` / `$filter` / a filter - AST (#7534), a dotted `fields` / `$select` entry (#7532, which used to widen - the response to every field), and a repeated `?filter=` (#7390). -- **A bulk write that stops now reports every record** — `NOT_ATTEMPTED` rows - instead of a truncated `results` array — and a per-row `errors[].httpStatus` - carries the status its producer declared, in any spelling (#8570). -- **A hook refusal that declares `statusCode` reaches the wire with that - status** rather than `500 INTERNAL_ERROR` (#7525), a crashing hook body - answers the sanitised fault envelope rather than a raw `TypeError` at 400 - (#7543), and one error envelope now spans the record-sharing family (#8111), - the `/security/suggested-bindings` trio (#7981) and the `/security/explain` - pair (#8073). -- **`UNIQUE_VIOLATION` 409s name the conflicting field**, matching the bulk path - (#7821), and malformed flow definitions answer 400 `VALIDATION_FAILED` rather - than 500 on both `POST` and `PUT /api/v1/automation/:name` (#8055, #8123). -- **Five ledgered-but-dead routes are mounted, and the class that hid them is - gated** (#7526); the route ledger gains live-mount parity. -- **Exported `datetime` cells render in the business timezone, not UTC** - (#8373). - -#### Metadata, packages & authoring gates - -- **An authored OWD is required at the runtime object door** (#8600), completing - the #7891 flip — `runtimeTypes` gains `object`, and ADR-0094 R2's - external-wider arm retires. `METADATA_CREATE_SEEDS.object` authors its - org-wide default explicitly rather than leaning on what the runtime resolves. -- **`validateSecurityPosture` gates runtime publishes** for `permission` / `book` - writes (#8309), the ADR-0091 seed pair (#8307) and the `views[]` - visibility-predicate family (#7220). -- **The `object-*` block family enters `ComponentPropsMap`** (#7751), so a - typo'd key inside a data-bound block's `properties` is caught at authoring - time with a did-you-mean. #4001 continues with batch A — the 31 SDUI - component-props shapes — and batch B, the memory driver's `persistence` - sub-shapes. -- **Uninstall stops leaving debris.** It removes the non-object metadata a - package shipped (#7221), no longer orphans env-wide `sys_metadata` rows - (#7705), and a refused uninstall now mutates nothing (#7970). Disabling a - package stops its objects being served, and a failed uninstall stops - answering 200 (#7557). -- **The `field` overlay lock is enforced at the live route** (#7743) — an - artifact-backed field PUT is refused rather than accepted 200 — and an - object's overlay row is treated as a base layer, not its resolved schema - (#8027). -- **A tenant's own rename beats a package extension's scalar** (#8620, - ADR-0029 D9.2a) — the one landing between this section's draft and the cut. - `mergeObjectDefinitions` applied an `objectExtensions` entry's `label` / - `pluralLabel` / `description` last, onto whatever base it was handed — and - D9.2 makes the tenant's overlay that base. So a rename saved through Studio - answered 200, was visible under `?layers=true`, and was overwritten inside - the fold before any read served it. An extender's scalar now applies only - while the fold's base still carries the packaged owner's value; a base that - has diverged was authored by the tenant, and the extender yields. It is the - comparison-based mechanism #8284 established for the i18n catalog one layer - up — the same predicate, now exported from `@objectstack/spec` and imported - by the registry rather than re-spelled — so one rule covers both layers. No - provenance flags and no migration: the question is answered from two values - at fold time. -- **`OS_METADATA_WRITABLE` no longer unlocks a write into a read-only package** - (#8146), and the metadata write refusal stops depending on deployment - topology (#8184) while reporting the package door it hit — - `ITEM_LOCKED` / `WRITABLE_PACKAGE_REQUIRED`. -- **`put().version` identifies the bytes actually stored** — the hash is taken - over the serialized form (#7856). -- **A plural metadata read can say it is known-partial** (#6504), and a - per-type read-path redaction seam lands in `kernel` with one definition of - "what is a credential key" in `data` (#8300). - -#### Drivers, query engine & analytics - -- **`$field` becomes a real cross-field comparison.** SQL push-down compiles it - to a column-to-column comparison (#5222); equality triples lower to - `{ $eq: ref }` (#7597); a field-to-field RLS rule is served on the analytics - path, with native SQL declining and routing to the engine (#7598); and the - comparand is refused on both SQL-lowering doors rather than being bound as the - comparison's *value*. The refusal names the author's own columns without - re-disclosing policy (#8220), and the two compared columns are withheld from a - cross-field refusal that would leak them (#7929, #7988). -- **MongoDB takes a structured `GroupByNode`**, answers `count` / `count_distinct`, - and buckets `dateGranularity` server-side with `supports.queryDateGranularity` - published (#7580); malformed `$between`, undeclared node-level `$`-keys and - `{ field: {} }` are refused (#5346, #5376). -- **An upsert whose `conflictKeys` have no backing unique index refuses - legibly** — in an ADR-0112 envelope rather than a raw `SqliteError` (#8445) — - on Postgres (#8567) and Turso as well as SQLite, with MySQL's behaviour - measured and pinned (#8592). -- **The first concurrent autonumber insert from two tenants no longer fails on - Postgres with `25P02`** (#8269), and the autonumber contract is stated: - unique and monotonic per scope, **not** gapless (#8283). -- **`ObjectQL.delete`'s by-id cascade is one unit of work** (#7413), `expand` - no longer silently no-ops when the nested `fields` omits `id` (#7537), and a - single-record update stops reporting the addressed row's own primary key as a - dropped field (#8093). - -#### Automation, jobs & the audit trail - -- **Automation runs record what triggered them, and keep it across a restart** - (#7533). A durable PAUSED run is visible to `listRuns` and run-detail after a - cold restart (#8050), its variable snapshot is readable on run-detail - (#7639), and a caught `try_catch` failure records what failed, how many - attempts ran and which node threw (#7546). -- **Scheduled and time-relative flows re-bind after a kernel rebuild** instead - of failing permanently, and a metadata reload reconciles declarative - connectors rather than no-op'ing against a stale registry (#7742). -- **Runs that did not do their work stop being audited as success** — a - timed-out job run records `timeout` (#7734) and a run that finishes without - doing its work records `degraded` (#5548). A durable `http` callout is - `unmeasured`, not `acted` (#7882), and a notify summary no longer reports a - delivery the delivery record dead-lettered (#7747). -- **The audit trail gets its missing writers.** Sign-in and sign-out are - recorded with the actor and the tenant (#8144); settings writes reach - `sys_audit_log` as `config_change` (#8145); the whole metadata lifecycle is - recorded, not only `save`; `publishPackageDrafts` writes the rows a batch - publish always owed; and a publish refused by a lock or a 409 leaves its own - audit row, outside the transaction so a rollback cannot take it (#8594, - #8400). A bearer-authenticated metadata write is attributed to the caller - rather than to `system` (#7749). - -#### New backend capabilities since rc.6 - -- **`OS_ARTIFACT_URL` — artifact-pinned boot** (#8368): boot a stack from a - published artifact by reference, with an SRI-style fragment pin. -- **`internal: true`** (#7728) — a field whose value is never returned on the - generic data path, with the write-response guarantee guarded as a property - rather than one class. -- **The `viewItems:` channel** (#5320, #8070) — a portable home for - non-container view artifacts in runtime-assembled manifests, with `views:` - tightened to the declared container-only contract. -- **The `security` service publishes the caller's resolved permission SETS** - (#7616), and `security/explain` gains a batch form — `recordIds: string[]`, - max 200, mutually exclusive with `recordId` (#8326). -- **Author-facing declarations that now hold**: `publicPicker` on - `FormFieldSchema` (#7467), `useGrouping` on `Field.number` (#7768), a declared - currency `precision` checked against ISO 4217 (#7918), `userActions.create` / - `.import` accepting the same CEL-predicate object form as `edit` / `delete` - (#7692), a `flows` surface on `TranslationBundle` for screen-flow wizard copy - (#7646), an optional `subCaption` on the dashboard widget translation node, - and an optional `object` on `GlobalFilterSchema` for i18n label resolution - (#7804). -- **`POST /auth/change-email` works** (#7735) — better-auth's `user.changeEmail` - is configured, with verification — and it notifies the **previous** address - without gating on it (#8019). -- **Verification harness**: `bootStack({ orgContext: true })` gives a harness - admin an organization-carrying execution context (#7762), and `--rls` runs one - probe persona per **declared position** so app-authored narrowing is actually - exercised (#7978) — after a fix for `--rls` reporting 0 HOLES over a probe - that could not reach the class it claimed to prove (#7685). -- **The multi-node gate can carry an admitted node count**, so a license cap - refuses the excess replicas rather than the whole cluster. - -#### New in Console — bundled objectui advanced `0cf8f0f70d10 → 665661ab0932` - -**Three pin moves**, released as **@objectstack/console 17.0.0** — the -enumerations are `console-6314e87f2d49`, `console-6d77acfe3125` and -`console-665661ab0932`: - -| Range | objectui commits | changesets releasing | -| :-- | --: | --: | -| `0cf8f0f70d10 → 6314e87f2d49` | 43 | 27 of 30 | -| `6314e87f2d49 → 6d77acfe3125` | 123 | 99 of 115 | -| `6d77acfe3125 → 665661ab0932` | 82 | 72 of 78 | - -`@objectstack/console` ships a frozen SPA build at the pin SHA and does not -forward objectui's type entrypoints, so **no new metadata migration is -registered for these moves** — each changeset records that verdict against -ADR-0087, and each lists by subject (not by count) the commits in its range -that carried no changeset of their own. - -The through-lines across the three: **the display locale finally reaches every -channel** — dates, currency, percentages, gantt and timeline labels, dashboard -and report measures, the null-value bucket, the Settings namespace screen, and -the organization/invitation console, closing the long tail where a `zh` session -read half its output in English. **Inspectors block Save on CEL parse faults**, -so a hook guard, action predicate, validation rule, page-block condition or -formatting rule that does not parse no longer saves and publishes as the live -definition. **The editable dashboard grid renders dataset-bound widgets — and -says so visibly when it cannot**, instead of a silent blank chart. And the -permission matrix models the server: it honors `allowRuntimeCreate`, names the -gate that tripped in its read-only badge, and models the artifact tier so a -Save cannot 403 on a code-declared set. - -Notable single fixes: impersonation takes effect at all on the data lane -(`set-auth-token`, #4467) with a standing banner and a loudly-failing exit; -cross-page "select all N matching" replays the host's real query or abstains -rather than fanning out unfiltered; lookup values link to the referenced -record; an `OBJECT_API_DISABLED` list renders an honest cannot-work state -instead of the empty state; `deleteView` removes every home a view has; -`exportOptions` follows the spec's object form with a legacy bare array lifted -by the bridge; and `crypto.randomUUID` is restored on insecure origins so list -views stop crashing on LAN IPs. - - ---- - diff --git a/content/docs/releases/v17/index.mdx b/content/docs/releases/v17/index.mdx index 167d06be9d..6ef6299f2d 100644 --- a/content/docs/releases/v17/index.mdx +++ b/content/docs/releases/v17/index.mdx @@ -57,7 +57,7 @@ Each release below is a self-contained page: what it changed, what breaks, and i - **[17.3.0](/docs/releases/v17/17.3)** - **[17.2.0](/docs/releases/v17/17.2)** - **[17.1.0](/docs/releases/v17/17.1)** -- **[17.0.0](/docs/releases/v17/17.0)** — the major cut ([pre-release train](/docs/releases/v17/17.0/prerelease)) +- **[17.0.0](/docs/releases/v17/17.0)** — the major cut ## Upgrade checklists diff --git a/scripts/role-word-baseline.json b/scripts/role-word-baseline.json index 2572a946c3..2461893872 100644 --- a/scripts/role-word-baseline.json +++ b/scripts/role-word-baseline.json @@ -31,7 +31,7 @@ "content/docs/releases/v13.mdx": 15, "content/docs/releases/v14.mdx": 5, "content/docs/releases/v16.mdx": 5, - "content/docs/releases/v17/17.0/index.mdx": 2, + "content/docs/releases/v17/17.0.mdx": 2, "content/docs/ui/forms.mdx": 3, "skills/objectstack-ai/SKILL.md": 1, "skills/objectstack-automation/SKILL.md": 1, From e3c2866408ba800e847070bc3589077728fd4837 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 08:44:17 +0000 Subject: [PATCH 4/5] docs(releases): give the v17 minor pages dotless slugs so link checking resolves them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v17 split landed the per-minor pages as `17.0.mdx` … `17.4.mdx`, and "Check Documentation Links" went red with 20 `File not found` errors, all of them `/docs/releases/v17/17.N`. Root cause, reproduced against the pinned lychee 0.24.2 with the workflow's own argv and isolated in a three-link fixture: /docs/t/17.3 (17.3.mdx exists) -> ERROR File not found /docs/t/17-3 (17-3.mdx exists) -> OK /docs/t/plain (plain.mdx exists) -> OK `--fallback-extensions mdx,md` does not fire when the last path segment already contains a dot — lychee reads `.3` as the extension and never tries `.mdx`. It is the filename shape, not the split, that the checker cannot follow. So the slugs lose the dot. Nothing else moves: * `git mv` 17.N.mdx -> 17-N.mdx, contents untouched; * every `/docs/releases/v17/17.N` link rewritten to `17-N`; * `v17/meta.json` pages renamed to match; * the role-word baseline key follows the file (count stays 2 — pure relocation, the one path that gate documents as legitimate). Frontmatter titles are unchanged, so the sidebar still reads 17.0.0 … 17.4.0 — the slug is invisible to readers. Verified locally with the workflow's exact command line: 2152 links, 0 errors (was 20). check-release-notes, check-release-page-status, check-doc-anchors, check-docs-single-h1 and check-role-word all pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016M1gHPfToXY4sUdzsD2CHa --- .../docs/releases/v17/{17.0.mdx => 17-0.mdx} | 0 .../docs/releases/v17/{17.1.mdx => 17-1.mdx} | 0 .../docs/releases/v17/{17.2.mdx => 17-2.mdx} | 0 .../docs/releases/v17/{17.3.mdx => 17-3.mdx} | 0 .../docs/releases/v17/{17.4.mdx => 17-4.mdx} | 6 +++--- content/docs/releases/v17/index.mdx | 20 +++++++++---------- content/docs/releases/v17/meta.json | 10 +++++----- content/docs/upgrading.mdx | 10 +++++----- scripts/role-word-baseline.json | 2 +- 9 files changed, 24 insertions(+), 24 deletions(-) rename content/docs/releases/v17/{17.0.mdx => 17-0.mdx} (100%) rename content/docs/releases/v17/{17.1.mdx => 17-1.mdx} (100%) rename content/docs/releases/v17/{17.2.mdx => 17-2.mdx} (100%) rename content/docs/releases/v17/{17.3.mdx => 17-3.mdx} (100%) rename content/docs/releases/v17/{17.4.mdx => 17-4.mdx} (99%) diff --git a/content/docs/releases/v17/17.0.mdx b/content/docs/releases/v17/17-0.mdx similarity index 100% rename from content/docs/releases/v17/17.0.mdx rename to content/docs/releases/v17/17-0.mdx diff --git a/content/docs/releases/v17/17.1.mdx b/content/docs/releases/v17/17-1.mdx similarity index 100% rename from content/docs/releases/v17/17.1.mdx rename to content/docs/releases/v17/17-1.mdx diff --git a/content/docs/releases/v17/17.2.mdx b/content/docs/releases/v17/17-2.mdx similarity index 100% rename from content/docs/releases/v17/17.2.mdx rename to content/docs/releases/v17/17-2.mdx diff --git a/content/docs/releases/v17/17.3.mdx b/content/docs/releases/v17/17-3.mdx similarity index 100% rename from content/docs/releases/v17/17.3.mdx rename to content/docs/releases/v17/17-3.mdx diff --git a/content/docs/releases/v17/17.4.mdx b/content/docs/releases/v17/17-4.mdx similarity index 99% rename from content/docs/releases/v17/17.4.mdx rename to content/docs/releases/v17/17-4.mdx index 8cb4cf5163..db025b4173 100644 --- a/content/docs/releases/v17/17.4.mdx +++ b/content/docs/releases/v17/17-4.mdx @@ -5,7 +5,7 @@ description: "Release notes for 17.4.0 of the v17 line, published 2026-09-09. ⚠️ **This page is a machine draft, not curated release notes.** It carries the same section -shape as [17.3.0](/docs/releases/v17/17.3) and its siblings, so a reader lands where they +shape as [17.3.0](/docs/releases/v17/17-3) and its siblings, so a reader lands where they expect — but only the shape is finished. The content was generated by de-duplicating the **396 changesets** that make up 17.4.0 across all 61 package `CHANGELOG.md` files (a changeset that bumps six packages appears once here, not six times) and taking each one's own headline @@ -20,7 +20,7 @@ rather than being filled with a guess. **How to use it.** Prune. What survives, grouped and rewritten, is the release section. -**Published 2026-09-09**, taking over from [17.3.0](/docs/releases/v17/17.3) as the current +**Published 2026-09-09**, taking over from [17.3.0](/docs/releases/v17/17-3) as the current version of the v17 line. 396 changesets across 61 packages: 35 carrying a breaking marker, 140 other `minor` entries, 221 `patch` entries. @@ -433,7 +433,7 @@ Distribution across the packages they touched: lowers array analytics filters"* (#16788). ⛔ The intermediate bumps are not enumerated here: this checkout's history for `.objectui-sha` does not carry all of them, and guessing the chain would be worse than naming the gap. Whoever curates this page should walk the pin's real -history and list the moves the way [17.3.0](/docs/releases/v17/17.3#new-in-console-studio--objectui-pins-in-1730) does. +history and list the moves the way [17.3.0](/docs/releases/v17/17-3#new-in-console-studio--objectui-pins-in-1730) does. ## Upgrade checklist diff --git a/content/docs/releases/v17/index.mdx b/content/docs/releases/v17/index.mdx index 6ef6299f2d..915da36228 100644 --- a/content/docs/releases/v17/index.mdx +++ b/content/docs/releases/v17/index.mdx @@ -40,12 +40,12 @@ parsed-but-never-enforced spec clusters are removed rather than maintained. > makes a permission-store outage fail loudly, and tenant-scopes > `sys_record_share` with an operator-invoked backfill for the rows written > before it. Read **[Breaking changes & migration in -> 17.2.0](/docs/releases/v17/17.2#breaking-changes--migration-in-1720)** and **[Breaking changes & -> migration in 17.3.0](/docs/releases/v17/17.3#breaking-changes--migration-in-1730)** before upgrading. +> 17.2.0](/docs/releases/v17/17-2#breaking-changes--migration-in-1720)** and **[Breaking changes & +> migration in 17.3.0](/docs/releases/v17/17-3#breaking-changes--migration-in-1730)** before upgrading. > > ⛔ **17.4.0's notes are a machine draft, not curated.** 396 changesets across 61 > packages landed in it, 35 of them carrying a breaking marker, and no editorial -> pass has been made — see **[17.4.0](/docs/releases/v17/17.4)**, which says so at +> pass has been made — see **[17.4.0](/docs/releases/v17/17-4)**, which says so at > the top and lists what it does and does not cover. Its upgrade checklist is > **not written**: nobody has walked 17.3.0 → 17.4.0. @@ -53,11 +53,11 @@ parsed-but-never-enforced spec clusters are removed rather than maintained. Each release below is a self-contained page: what it changed, what breaks, and its own upgrade checklist. -- **[17.4.0](/docs/releases/v17/17.4)** — current -- **[17.3.0](/docs/releases/v17/17.3)** -- **[17.2.0](/docs/releases/v17/17.2)** -- **[17.1.0](/docs/releases/v17/17.1)** -- **[17.0.0](/docs/releases/v17/17.0)** — the major cut +- **[17.4.0](/docs/releases/v17/17-4)** — current +- **[17.3.0](/docs/releases/v17/17-3)** +- **[17.2.0](/docs/releases/v17/17-2)** +- **[17.1.0](/docs/releases/v17/17-1)** +- **[17.0.0](/docs/releases/v17/17-0)** — the major cut ## Upgrade checklists @@ -72,7 +72,7 @@ published documentation, the `CHANGELOG.md` files inside the published npm tarballs, and the `os` CLI's own output. Every line it produced is marked **measured**, and several of them are changes whose own changelog entries are not marked breaking — which is exactly why a checklist is not a restatement of -[Breaking changes & migration in 17.3.0](/docs/releases/v17/17.3#breaking-changes--migration-in-1730). +[Breaking changes & migration in 17.3.0](/docs/releases/v17/17-3#breaking-changes--migration-in-1730). ⛔ **That run covered one hop, 17.2.0 → 17.3.0. Nobody has walked 17.1.0 → 17.2.0.** Every line in the 17.2.0 list is derived from a change's own @@ -82,7 +82,7 @@ nobody has run, presented beside steps that were, is how a reader finishes a checklist and believes they are done. -Per-release checklists: [17.4.0](/docs/releases/v17/17.4#upgrade-checklist) · [17.3.0](/docs/releases/v17/17.3#upgrade-checklist) · [17.2.0](/docs/releases/v17/17.2#upgrade-checklist) · [17.1.0](/docs/releases/v17/17.1#upgrade-checklist) · [17.0.0](/docs/releases/v17/17.0#upgrade-checklist) +Per-release checklists: [17.4.0](/docs/releases/v17/17-4#upgrade-checklist) · [17.3.0](/docs/releases/v17/17-3#upgrade-checklist) · [17.2.0](/docs/releases/v17/17-2#upgrade-checklist) · [17.1.0](/docs/releases/v17/17-1#upgrade-checklist) · [17.0.0](/docs/releases/v17/17-0#upgrade-checklist) ## References diff --git a/content/docs/releases/v17/meta.json b/content/docs/releases/v17/meta.json index 03b49276ab..b64d139075 100644 --- a/content/docs/releases/v17/meta.json +++ b/content/docs/releases/v17/meta.json @@ -2,10 +2,10 @@ "title": "v17", "pages": [ "index", - "17.4", - "17.3", - "17.2", - "17.1", - "17.0" + "17-4", + "17-3", + "17-2", + "17-1", + "17-0" ] } diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index d9f4ba9376..3ec0727d73 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -295,11 +295,11 @@ have no other channel to reach you than the page below. | Release | Where its upgrade notes live | | :--- | :--- | -| v17.4.0 | [17.4.0](/docs/releases/v17/17.4) — ⛔ checklist not written; machine-draft notes only | -| v17.3.0 | [Upgrade checklist — 17.3.0](/docs/releases/v17/17.3#upgrade-checklist) | -| v17.2.0 | [Upgrade checklist — 17.2.0](/docs/releases/v17/17.2#upgrade-checklist) | -| v17.1.0 | [Upgrade checklist — 17.1.0](/docs/releases/v17/17.1#upgrade-checklist) | -| v17.0.0 | [Upgrade checklist — 17.0.0](/docs/releases/v17/17.0#upgrade-checklist) | +| v17.4.0 | [17.4.0](/docs/releases/v17/17-4) — ⛔ checklist not written; machine-draft notes only | +| v17.3.0 | [Upgrade checklist — 17.3.0](/docs/releases/v17/17-3#upgrade-checklist) | +| v17.2.0 | [Upgrade checklist — 17.2.0](/docs/releases/v17/17-2#upgrade-checklist) | +| v17.1.0 | [Upgrade checklist — 17.1.0](/docs/releases/v17/17-1#upgrade-checklist) | +| v17.0.0 | [Upgrade checklist — 17.0.0](/docs/releases/v17/17-0#upgrade-checklist) | | v16.0.0 | [Upgrade checklist](/docs/releases/v16#upgrade-checklist) | | v15.0.0 | [Upgrade checklist](/docs/releases/v15#upgrade-checklist) | | v14.0.0 | [Upgrade checklist](/docs/releases/v14#upgrade-checklist) | diff --git a/scripts/role-word-baseline.json b/scripts/role-word-baseline.json index 2461893872..f4bf00adcb 100644 --- a/scripts/role-word-baseline.json +++ b/scripts/role-word-baseline.json @@ -31,7 +31,7 @@ "content/docs/releases/v13.mdx": 15, "content/docs/releases/v14.mdx": 5, "content/docs/releases/v16.mdx": 5, - "content/docs/releases/v17/17.0.mdx": 2, + "content/docs/releases/v17/17-0.mdx": 2, "content/docs/ui/forms.mdx": 3, "skills/objectstack-ai/SKILL.md": 1, "skills/objectstack-automation/SKILL.md": 1, From 0644c1dafa74ec910d9e0fb5c3baee8a4023e491 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 09:08:12 +0000 Subject: [PATCH 5/5] fix(gates): regenerate the hand-written docs ledger, and stop the coverage gate skipping v17 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two consequences of the v17 split that CI found and the local run had not. 1. `scripts/docs-audit/handwritten-docs.json` is GENERATED output — the audit scope, derived as `content/docs/**/*.mdx` minus the generated references, with hand-editing rejected outright. The split moved six pages under it, so `check:docs-audit-scope` went red naming both directions of the drift: one listed path gone, six real pages unlisted. Regenerated with the gate's own `--write`; the diff is exactly those six for that one, nothing else moved. Left unfixed this is not cosmetic: a FULL audit would have called itself full while silently skipping every v17 page, and an agent pointed at the dead path would report "0 fixes" — indistinguishable in the summary from a page that was read and found accurate. 2. `check-release-section-coverage` looked only for a flat `v.mdx`, so after the split it printed "(v17: … skipped)" and then reported "2 GA major(s) in scope (v16, v17); every one has a heading on its major's release page". It was asserting nothing about v17 while naming it as covered. The skip is explicitly justified in the source by the two sibling gates going red on the same fact — and this PR taught both of them the folder layout, so that justification had gone. It now reads either layout, the folder as one corpus, because "some heading names this minor" is a property of the major's prose wherever it is written; the split moved `## What's new in 17.1.0` from line 3475 of one file to line 5 of another, which is not a coverage change. Proven to measure rather than merely stop printing "skipped": with every heading naming the 17.4 series mutated away, the gate reports the finding against `content/docs/releases/v17/` and exits 0 advisory / 1 --strict. Its 50-case self-test is unchanged and still passes. No gate is weakened and no baseline or exception list is added: one generated artifact regenerated by its own generator, and one lookup taught the second layout its two siblings already accept. check:docs-audit-scope, doc-anchors, doc-authoring, docs-redirects, docs-single-h1, release-notes, release-page-status, release-index-currency-sync, role-word, quick-reference-counts, corpus-claim-drift, published-readme-links, org-identifier and docs-image-tag all pass; lychee reports 2152 links, 0 errors. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016M1gHPfToXY4sUdzsD2CHa --- scripts/check-release-section-coverage.mjs | 45 +++++++++++++++++++--- scripts/docs-audit/handwritten-docs.json | 7 +++- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/scripts/check-release-section-coverage.mjs b/scripts/check-release-section-coverage.mjs index be01c9da14..fbded98a21 100644 --- a/scripts/check-release-section-coverage.mjs +++ b/scripts/check-release-section-coverage.mjs @@ -181,7 +181,7 @@ // changelogs" is exactly why a finding here is a report and not a build failure: // the process itself sanctions the other branch, and no gate should hard-fail a // state its own process document permits. -import { readFileSync, existsSync, appendFileSync } from 'node:fs'; +import { readFileSync, existsSync, readdirSync, appendFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, isAbsolute, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -376,6 +376,41 @@ export function coverageFindings(major, minor, pagePath, pageText) { ]; } +/** + * The corpus this gate reads for one major, across BOTH page layouts, or null + * when the major has no page at all. + * + * A major is either one flat `v17.mdx` or a `v17/` folder holding `index.mdx` + * plus one page per minor — the same two layouts check:release-notes and + * check:release-page-status accept, and neither is deprecated. The assertion + * here is "some heading NAMES this minor", which is a property of the major's + * prose wherever it is written down, so the folder is read as one corpus: the + * split moved the `## What's new in 17.1.0` heading from line 3475 of one file + * to line 5 of another, and that is not a coverage change. + * + * ⛔ Returning null on a folder that exists would be worse than the old flat-only + * lookup, not equal to it: the skip below is only honest while a sibling gate + * really does go red on the same fact, and once those two learned this layout a + * skip here became a silent hole reported as "in scope" by renderOk. + * + * @param {number} major + * @returns {{ pagePath: string, pageText: string } | null} + */ +export function releasePageCorpus(major) { + const flat = `${RELEASES_DIR}/v${major}.mdx`; + if (existsSync(flat)) return { pagePath: flat, pageText: readFileSync(flat, 'utf8') }; + + const dir = `${RELEASES_DIR}/v${major}`; + if (!existsSync(`${dir}/index.mdx`)) return null; + const files = readdirSync(dir) + .filter((f) => f.endsWith('.mdx')) + .sort(); + return { + pagePath: `${dir}/`, + pageText: files.map((f) => readFileSync(`${dir}/${f}`, 'utf8')).join('\n'), + }; +} + // ── Assertion 2: index currency ────────────────────────────────────────────── /** @@ -1210,14 +1245,14 @@ function main(argv) { const indexText = existsSync(INDEX_PATH) ? readFileSync(INDEX_PATH, 'utf8') : null; for (const major of inScopeMajors) { - const pagePath = `${RELEASES_DIR}/v${major}.mdx`; - if (!existsSync(pagePath)) { + const page = releasePageCorpus(major); + if (page === null) { // check:release-notes and check:release-page-status both already fail on // this. Reporting it a third time is three reds for one fix. - console.log(` (v${major}: no ${pagePath} — page existence is check:release-notes' verdict; skipped)`); + console.log(` (v${major}: no ${RELEASES_DIR}/v${major}.mdx and no ${RELEASES_DIR}/v${major}/ — page existence is check:release-notes' verdict; skipped)`); continue; } - const pageText = readFileSync(pagePath, 'utf8'); + const { pagePath, pageText } = page; for (const [maj, min] of minors) { if (maj !== major) continue; findings.push(...coverageFindings(maj, min, pagePath, pageText)); diff --git a/scripts/docs-audit/handwritten-docs.json b/scripts/docs-audit/handwritten-docs.json index dc81dcb245..5227d53423 100644 --- a/scripts/docs-audit/handwritten-docs.json +++ b/scripts/docs-audit/handwritten-docs.json @@ -191,7 +191,12 @@ "content/docs/releases/v14.mdx", "content/docs/releases/v15.mdx", "content/docs/releases/v16.mdx", - "content/docs/releases/v17.mdx", + "content/docs/releases/v17/17-0.mdx", + "content/docs/releases/v17/17-1.mdx", + "content/docs/releases/v17/17-2.mdx", + "content/docs/releases/v17/17-3.mdx", + "content/docs/releases/v17/17-4.mdx", + "content/docs/releases/v17/index.mdx", "content/docs/releases/v9.mdx", "content/docs/ui/actions.mdx", "content/docs/ui/apps.mdx",