17.3.0
Release notes and upgrade checklist for 17.3.0 of the v17 line.
Highlights — 17.3.0
client.projects.*becomesclient.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 noclient.projectsgetter and nores.project ?? res.environmenthedge — ADR-0006 D3 declined a mapping layer with reasons. The URL paths do not move; they were already on theenvironmentsspelling.os environments --format jsonpayloads 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.postureisinvite_only | email_domain | open, and an undeclared audience now meansinvite_only: self-serve sign-up is refused403 SELF_REGISTRATION_CLOSEDunless the address holds a pendingsys_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).tryFindanswered 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 raisesAuthzStoreUnavailableError, carrying the existingSERVICE_UNAVAILABLEcode and503. A reachable-but-empty store, an unprovisionedsys_*table and a genuine denial all keep their previous answers. driver-memoryenforces the uniqueness it always declared (56c093c,b7f645a).InMemoryDriverenforced none: aunique: truefield and auniqueobject-levelindexes[]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_shareis tenant-scoped, and the rows written before it need an operator-invoked backfill (3f64fe6). Every grant row on every deployment was written withorganization_id = NULL; the writer is repaired andplanSysRecordShareOrganizationBackfill/runSysRecordShareOrganizationBackfill(dry run first, by default) stamp the existing rows from the record they grant access to.POST /api/v1/automation/:name/togglerequiresmanage_metadata(266436a). An authenticated caller without it is answered 403PERMISSION_DENIEDwhere it previously received200with 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.explainandautomation.triggernow 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.triggeris the exception, becauser.successandr.errorcompile 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/:nameand the.../publishedsibling answer404 ROUTE_NOT_FOUND; address every item through the single-segment route with the name percent-encoded.@objectstack/clientalready sends the new spelling, and the encoding is a no-op for every name the item-name grammar admits. - The
RestServerConfigblocks are parsed rather than ignored (1394768,8965398,f60ab90,b3a63d3).config.api,api.projectResolutionandconfig.crudare now parsed by name, and the ten inert keys the liveness ledger recorded asdeadare 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
contributesblock loses its last ten members andmanifest:itself goes strict (be21955,bc56e18,dce5cd4,4d0d944),page.components[].responsiveand theResponsiveConfigvocabulary go (2a6122b), theallowRestore/allowPurgeobject-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 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(…) // nowThe 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_defaultpermission 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 refused403 SELF_REGISTRATION_CLOSEDunless the address holds a pendingsys_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)
| 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 nestedcontributes/kinds[]/engine/enginesblocks (4d0d944), and thecontributesblock loses its last ten dead members —events,menus,themes,translations,actions,drivers,fieldTypes,functions,commands(be21955, #10724) androutes(bc56e18, #10726) — as do the three dead top-level containerscapabilities,configuration,extensions(dce5cd4, #11332). page.components[].responsiveand theResponsiveConfiglayout vocabulary are retired (2a6122b, #11027).- The
allowRestore/allowPurgeobject-permission bits are retired — declared gates on operations that do not exist (8af88dd, #12497). lookup/master_detailfields require a non-emptyreference(0fb8760, #13632).- An
autonumberfield isunique: 'organization'by default; explicitunique: falseopts out (f8e8f03, #13894). Read this beside thedriver-memoryenforcement above — the two compose. nullis refused in comparand positions:$in/$ninmembers and$betweenbounds (e398863), and the ordering comparands$gt/$gte/$lt/$lte(d16df74).addressandlocationvalues refuse undeclared keys (d62f990, #13802).- An authored
radiowithmultiple: trueis refused at the schema layer (348860c, #11437). - Calendar in
appearance.allowedVisualizationsrequirescalendar.startDateFieldon list views (96e25a8, #13817). defineStackrefuses two actions resolving to one scope-qualified runtime key, andcomposeStacksrefuses two input stacks that do (279431e,35dffea);defineStackalso refuses a stack declaring an auto-launched flow whilerequiresomits'triggers'(948dd6b, #14153).- A
type: 'script'action may not declare both post-success navigation channels —onSuccessbesideopensInNewTab: trueis refused, andnewTabUrlwithoutopensInNewTabis refused (387e231,2d4fa75). Pick one destination: keeponSuccess, or keepopensInNewTabplus the handler's returnedredirectUrl. ComponentPropsMapconvergences:element:number'sfilteronto theViewFilterRulearray form (79b6a22),object-grid'sdataontoViewDataSchema(8f79379),object-grid's legacydefaultSortfallback retired (e6ca40e), the per-optiondefaultkey narrowed out of the form-view options vocabulary (c459da6), anduser:profilerefused by name as not author-placeable (97a2263).- The
sys_scim_providerplatform object is retired (4d25d22, #11757), and with it theAUTH_SCIM_PROVIDER_SCHEMA/AUTH_SSO_PROVIDER_SCHEMApublic 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:formelement at element grain (7345308, #9249), the import-mappinglookuptransform's steering params (15d58db, #10329), the authorableAdvancedPluginLifecycleConfigsurface (40a93b5, #11825),HotReloadConfig.distributedConfigandwatchPatternstogether with the twostateStrategyvalues that were never implemented (4635f3e, #12340;ee3595c, #12428), the inertPluginMetadatasurfacesconfigSchemaandhotReloadable(49f0dcf, #11982, #12587), the threePluginHealthCheckrestart keys the monitor never performed (b72db01, #12032), the component-translationsubmitLabelcopy 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: trueupdate whose per-rowbeforeUpdatehooks write divergent key sets is refused (dee4dd4, #14099), and an undeclared field abefore*hook writes is refused identically on every driver (b003cf2). - A third
UPDATE_ID_MISMATCHshape refuses — a bound truthy scalar payloaddata.idbeside a declared but non-scalaroptions.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 answersnullon MongoDB and on Turso's remote face (ca3fd4b), anddriver-memory'supdate()/upsert()publish their honest types (93940d4).redshift/cockroachdbDDL is refused by name andpgnativejoins the Postgres family (dfebfc8, #11991); the danglingpostgresandnatsvalues leaveClusterDriverSchema(c85a265, #13393).sys_oauth_resource.identifiernarrows 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.eligibilityandpublicSharing.enabledare 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.enableddefaults totrueand the SLA sweep finally reads it (277948f, #12278) — an approval process that declared escalation and saw none now escalates.controlled_by_parentcomposes across a chain: a child whose master is itself derived is no longer readable and writable org-wide (6171331, #11082).ExecutionStepLog.iterationis single-valued — the enclosing loop iteration — and the parallel branch index moves to a new optionalbranchkey (8ab926b, #14414).os servedefaultsNODE_ENVtoproductionwhen unset, exactly asos startalready did (918988a, #11113);os validate --json --strictexits 1 on the configs--strictalready 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
PackageRollbackResponseSchemais retired (dc75ba8, #12038);meta.deleteItemdeclares the response the reset door actually sends (426ad58, #13023);SaveReportInput's requirements are stated at thereports.savedoor (3519f8d, #11926). A TypeScript caller reading a member the route never sent now sees it at build time. - A published package must declare an
exportsmap, and that is now a gate (6a571d3, #12879). Plugin.typeis the closed set the spec declares — aPluginTypederived fromCORE_PLUGIN_TYPES(d8024f0, #13925), so a plugin declaring an off-vocabulary type is refused rather than silently unrouted.getUiView's list branch honourshiddenon 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-Matchentity-tag is refused instead of silently disabling optimistic concurrency (47389b3, #13576). - The flat-input proxy refuses a symbol key at
set/definePropertyinstead 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-levelfailed, loop iteration throughtry/catch, row identity on$error(18d816a, #13681). - Wizard view v1 tightens
FormViewSchematype: 'wizard'into a declaration-and-refusal shape (4bc18e5, #13704);cloud-connection:panel,marketplace:installed-list(772d5de) andmcp:connect-agent(ce80ec2) are declared inComponentPropsMap, so undeclared keys on those three widgets are refused; andKnowledgeRefreshPolicy.cronis typed with the shared cron dialect (778c59f, #14825). - Deleting a datasource evicts its driver from the data-engine registry, so
/api/v1/readyrecovers 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.jsoncodec becomes injective, one encoding across all three dialects (4045b95, #12380), and MySQL stampsupdated_atat 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.introspectSchemadeclares the spec introspection contract (3d79144),StrategyContext.executeAggregate'saggregations[].methodnarrows fromstringtoAggregationFunction(d028b37, #12776),INotificationOutbox.ack()takes back the claimed record instead of a bare row id (d9cf78e), andsweepOrphanedRowsByRecordExistence'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).composeStacksgainsmanifest: 'preserve'so N package identities survive composition (2e3e8c7), andEnvironmentArtifactSchemadeclaresgrantedPermissions— the install-time granted permission set per plugin (e58ea8b). - A row action gets the declarative single-record field write —
operation: 'update'pluspatch, with no handler to write (effae80, #14092). - A layout section can reference a declared field group instead of copying its
members (
39404f3, #13855), andfieldGroups[].visibleWhenreturns — this time with its enforcement (53dc739, #12715). - An already-published page can be mounted on an object view — a
pagemember on theviewtype enum (d23dc08, #13216) — and the command palette indexes published pages through a page hit kind onGET /api/v1/search(e764507). - A sharing rule can share each matched record with the user or users named by a
field on that record —
ShareRecipientTypegainsfield(0f94cc7). sys_user.localeis 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'sAccept-Languagewith the deployment default second (4bb412b, #14319).- A rank-and-file member may edit their own
sys_userrow (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.invalidatedchannel (4bd6faa) — plus a cross-request authorization grants cache (86cbe37, #11971) and a cached, synchronously invalidatedsys_settinglocalization 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 lintrefuses 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 jsonfailure envelopes carry the ADR-0112codeandhttpStatus(098a08f, #13347), andos validate --json/os build --jsoncarry the computed advisory lists andconversionson every failure exit, not the success payload alone (33e81a5,d114d5e,79cf692).os lintsurfaces ADR-0087 conversion notices (9fd45a9, #12297).os migrate planreports the platform-namespaced tables no declaration accounts for (3f7f8f5, #13204), andos migrate applyrefuses before writing any DDL when the host config exists but could not be loaded (b4f2cda, #13118).meta.saveItemcan send theIf-MatchOCC header it already told callers to send (6274a1a, #11713), andmeta.deleteItem— withos meta delete— can pin a reset and discard only the pending draft (cf71d73,2331b1e).AutomationResult.statusnames the terminally-failed-but-repairable run,'stranded'(bd4aa4e, #14384),AutomationContext.recordLoadDeniedgives the flow the caller-scope record-load signal (63cd487, #14244), andFLOW_INPUT_SCHEMA_INVALIDbecomes a registered never-dispatched exit (f90e820).DataEventandBulkDataEventname 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) — andeditMode?: 'modal' | 'page'on the object document (f11fc61, #11408).- The SQL connection pool is sized from
OS_DATABASE_POOL_MAX(c4e8bbc); SQLite-family JSON columns declareTEXTwhile server dialects keep native JSON (9f4a6d5, #12738); anddriver-mongodbindexeslookupjoins off the canonicalreferencekey (eaba72e, #13222). @better-auth/scimmoves from1.7.0-rc.1to stable1.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 leavespecVersion/engines.protocolalone — 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 17to reportNothing 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 printsruntime 17.0.0on 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-relationrefuses an object that declaressharingModel: 'controlled_by_parent'when two or more of its fields tie for the master relation — because the resolver picks the master withArray.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 requiredmaster_detailtier 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 validatefor the rest of the author-time refusals, which are parse-time and name the key: strict plugin manifests, retiredpage.components[].responsive,allowRestore/allowPurge,lookup/master_detailrequiring a non-emptyreference,nullrefused in$in/$nin/$between/ ordering comparands, undeclared keys onaddressandlocationvalues,radiowithmultiple: 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 lintitself 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_validationsandbulkActions). 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
autonumberfield that omitsuniquenow parses tounique: '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 theerrorchannel, and the drift pass names the conflicting key groups with row counts. Runos migrate duplicates, deduplicate, then re-runos 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=grouporisolated) must declareOS_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 —hasPlatformAdminGrantis 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 toinvite_only | email_domain | openand defaults toinvite_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/togglerequiresmanage_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 gradedminordeliberately, 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 adeletePropertytrap, and the sandbox path diffs deletions against the entry snapshot instead of usingObject.assign, which cannot represent a removal. If a hook needs a column to arrivenullrather 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.0driver-memoryaccepted and stored a key the object never declared — outside field-level security by construction, since an undeclared field can carry nofieldPermissionsentry — while the SQLite drivers refused it as a raw error carrying neither an ADR-0112codenor astatus. All three now answerINVALID_FIELD/400. Real behaviour change for any app whose hook quietly wrote an undeclared key ondriver-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, plusNOW()/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 writtenundefined, and the unwrapped formLOWER({x})interpolated to the literal textLOWER(ACME Corp)and landed in the database looking as though it had worked. - Tests that pin the record-level
FORBIDDENmessage text will fail. Measured. The message is user-facing prose now (insufficient privileges to update crm_contractbecameYou do not have access to change or delete this record. …).codeandstatusare 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 frozenctx.input.options({ multi, where }) projection cross the sandbox boundary;ctx.input.idstill does not, andscopedoes not cross withdispatch. 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/clisubpaths stop resolving (#15325). Measured. 17.2.0 published noexportsfield; 17.3.0 adds one admitting only.and./console, so an import of anything else failstscwith TS2307 and Node withERR_PACKAGE_PATH_NOT_EXPORTED.@objectstack/cli/package.jsonis 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.*toclient.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, andos environments --format jsonchanges 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_MISMATCHshape refuses — a truthy scalardata.idbeside a declared but non-scalaroptions.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.