17.2.0
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/deletewhosewherenames anything besidesidnow 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-idupdatewhosedata.idandwhere.idare both truthy scalars that disagree now refusesUPDATE_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 — flowupdate_record/delete_recordnodes that namedidplus other filter keys without declaringmulti: trueare 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 areMetricSchema.filters, the per-metric raw-SQL filter nothing read (#10414), therecord:highlightsfieldicon(#10054), and thethemescarrier key plusThemeSchema—app.brandingremains the one color-authoring surface (#10485). http_request_errors_totalis 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 reachengine.aggregateunchecked, 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-levelfiltergets the identical guard (#10861). - Driver introspection stops guessing.
driver-sql'sintrospectPrimaryKeys/introspectForeignKeys/introspectUniqueConstraintsused 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 agentis 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@capabilitieshook-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_IDnow 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
wherenaming a scalaridand nothing else is unchanged — by-id, with or withoutmulti: true. - A
wherecarrying a scalaridplus other keys, with a declaredmulti: true, now routes to the predicate path (driver.updateMany/driver.deleteMany), which compiles everywherekey. Previously this dispatched by-id and dropped the extra keys. - The same shape without
multi: true— and any by-id call via a scalardata.idbeside extrawherekeys — 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 agentis 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,askandbuild, 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
@capabilitieshook-body directive comment is retired (7940de5, #10917). Nothing an author wrote has to change:loadConfigruns every config throughbundle-requireand 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 inbody.capabilitieson 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 a401whose body was the empty string under anapplication/jsonheader (4d7c564, #10349). Statuses and admission are unchanged; what is added is the machine-readablecode. IHttpOutbox.redeliverandMessagingService.redeliverHttpchange signature so the caller's tenant is threaded rather than swept globally (cdaa72f, #10740).SqlHttpOutbox.redelivernow rides the predicate path, so a delivery row claimed between its read and its write is not reset andredeliverreportsDELIVERY_NOT_ELIGIBLEinstead 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 —errorstays optional, call sites keep thelogger?.warn?.(…)backstop, and no runtime behaviour changes. It can break a host that injects its own reduced sink. driver-sql'sintrospectSchema()emits the spec introspection contract —primaryKey,dialect,introspectedAt(95437e7, #10676, #10998).- The
/metaFSM state route is singular:meta.getLegalNextStatesmoves and the plural registration is retired (67630c4, #10077).
New capabilities in 17.2.0
lifecycle.ttlaccepts anonlyWhenrow filter mirroringretention.onlyWhen, and the sharedonlyWhenvalue union gains the platform's canonical relative-date vocabulary (8012960).os g skill NAMEscaffolds an AI skill, written asNAME.skill.tsso the loader finds it (1c3a46f, #11025) — the replacement surfaceos g agentnow 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. sharingModelon the solution blueprint's object schema — the enumprivate | public_read | public_read_write | controlled_by_parentreaches the design stage (7d2d112), andnameFieldjoins the strict blueprint mirror (ceb33a9).themeandanalytics_cubeare validated at the/metawrite door (2306a76, #10194), so a Studio or API write is judged by the same rules as anos build.os migrate duplicatesreports the rows blocking the threekernel:readyuniqueness reconciliations (2866d5f) instead of leaving a boot to fail on them.MetadataRepository.watch()replays from the durable log on a numericsince(f334d66), so a reconnecting watcher no longer silently starts from now.- The runtime publish gate reaches further: list-view field rules judge a
standalone
viewwrite, and a standaloneViewItemrecord's nestedconfig.sort/config.searchableFieldsare judged there too (adbcbfd,f1b5ad3,def0d3e) — Studio and metadata-API writes are held to the rulesos buildalready applied. os lintreports an unparseable source instead of scoring it CLEAN (78818ec, #10653);os serverefuses a relativeplugins: [...]entry, naming the two spellings that work (e598b1c).- Time-relative sweeps are idempotent per matched window (
73d9795, #10220);sys_sessiongains a declared ADR-0057 lifecycle policy (dccbcec, #7826); themanagerapprover is screened to the request's organization (13f533a, #10153); and the__searchcompanion 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.actoris removed fromMetadataClient'ssave/reset/publish/rollback, and theX-Actorrequest header is no longer emitted (objectui8e00bfd28). The six retired@objectstack/spec/uitheme-schema re-exports leave@object-ui/types/zodwith thethemesretirement above (objectui920165d18), andThemeComponentSchema(type: 'theme') — a component kind no renderer implemented — is retired (objectui78cbdb530), as is the register-meta keydefaultChildren(objectuifa429cf6f). stackreads its spacing fromgapand nothing else — the undeclaredspacingkey it also accepted is gone (objectuidd194635d).- Authored predicates are actually consulted:
record:alertbinds its row throughusePredicateRecordContextsoproperties.visibleis read (objectui8a4439081), and a hoistedproperties.visiblestops swallowing a declaredvisibleWhen(objectuic86185eb5). object-grid/object-form/detail-viewresolve their data source the same way, and a block that resolves none says so (objectuiebce5a367);ReportViewreadsdataSource.object, the one key the contract declares (objectui60d452ee0);data-tablereads the declaredheader(objectuie719ebdd9).- Create forms pre-fill the
current_userdefaultValuetoken with the acting user (objectui3c9fca3dc); three more secret-field spellings stop rendering a secret in clear text on the unregistered-widget branch (objectui91783c47b); and a screen flow's resume result reaches the user on both outcomes (objectuic40f3b8ca).
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.
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; 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
wherenamesidplus 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 butidand landed unconditionally. Declaremulti: trueto have the fullwherehonoured (the result becomes a matched count), or drop the extra keys to keep an unconditional single-row write. Flow authors reach this throughupdate_record/delete_recordnodes whosefilternamesidalongside other keys — those configs were unconditional before and refuse loudly now. - Make
data.idandwhere.idagree, or drop one of them. A by-id update naming two different rows answersUPDATE_ID_MISMATCH/400, including ids that differ only in type (42beside'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 tohttp_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 ofSEMCONV.httpRequestErrorsTotal/RUNTIME_METRICS.httpRequestErrorsTotal;tscwill point at the site. - Delete four retired authorable keys, then re-run
os validate:sys_position.permissions,MetricSchema.filters, therecord:highlightshighlight-fieldicon, and thethemescarrier 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'sdatasourcecommands). These routes admitted any authenticated caller before and now requiremanage_platform_settingsormanage_metadataper route, answering403 PERMISSION_DENIEDby name.admin_full_accessalready carries both; a purpose-built operator set is the case to check. - Remove
os g agentfrom 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 reachaggregateunchecked and answer the wrong number; both analytics doors and a dataset's definition-levelfilternow refuse it the way a top-level one already did. - A publish naming
?package=no longer finds a package-less draft — it answers404 [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.redeliverHttpmove to the new signature (the caller's tenant is threaded rather than swept globally), and a delivery row claimed between read and write now reportsDELIVERY_NOT_ELIGIBLEinstead of success. meta.getLegalNextStatesis singular — the plural route registration is retired.