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.mdx b/content/docs/releases/v17/17-0.mdx new file mode 100644 index 0000000000..4b9048dead --- /dev/null +++ b/content/docs/releases/v17/17-0.mdx @@ -0,0 +1,1919 @@ +--- +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 ⛔ 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 + +⚠️ 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..db025b4173 --- /dev/null +++ b/content/docs/releases/v17/17-4.mdx @@ -0,0 +1,449 @@ +--- +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 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 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. 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. + +## 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, 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` +- `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 *(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` +- `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 — 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. + +### 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 +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 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 +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..915da36228 --- /dev/null +++ b/content/docs/releases/v17/index.mdx @@ -0,0 +1,199 @@ +--- +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 + +## 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..b64d139075 --- /dev/null +++ b/content/docs/releases/v17/meta.json @@ -0,0 +1,11 @@ +{ + "title": "v17", + "pages": [ + "index", + "17-4", + "17-3", + "17-2", + "17-1", + "17-0" + ] +} diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index 5bd97108de..3ec0727d73 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/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", diff --git a/scripts/role-word-baseline.json b/scripts/role-word-baseline.json index 101e085dac..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.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,