ObjectStackObjectStack

17.3.0

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), ScopedProjectClientScopedEnvironmentClient) and the response keys (res.projectsres.environments, res.projectres.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.rowsr.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 major100 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 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:

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 ScopedProjectClientScopedEnvironmentClient. 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.

beforeafterwhere
res.projectsres.environmentslist (the total key is unchanged)
res.projectres.environmentget, 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:

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)

beforeafter
authenticated caller with manage_metadata200, flow toggled200 — unchanged
authenticated caller without it200, flow toggled403 PERMISSION_DENIED
anonymous caller401401 — unchanged
engine self-invocation (isSystem)200200 — 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).

methodresolves to (now)rewrite
client.analytics.query(q)AnalyticsResultr.data.rowsr.rows
client.analytics.meta(cube?)the bare cube listr.data[0].namer[0].name
client.analytics.explain(q){ sql, params }r.data.sqlr.sql
client.automation.trigger(name, payload)AutomationResultr.data.statusr.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 answeringuse instead
GET /api/v1/meta/:type/:section/:nameGET /api/v1/meta/:type/:name
PUT /api/v1/meta/:type/:section/:namePUT /api/v1/meta/:type/:name
GET /api/v1/meta/:type/:section/:name/publishedGET /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 channelsonSuccess 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 writeoperation: '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 recordShareRecipientType 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.

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 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.
  • 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. 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 planuntil 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.<field> — 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). 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 — 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.

On this page