17.6.0
Release notes and upgrade checklist for 17.6.0 of the v17 line.
Highlights — 17.6.0
- A caller that resolves no permission set gets the deny baseline
(
62b90d7, #21217;665cab3, #21134;a9d36d5, #21051). A non-system caller that carries a principal but resolves no permission set used to be admitted to every object no set grants, with every field served as stored. It is now refused on every object, served gated fields masked or not at all, and refused any query on them. ⚠️ An embedder that setsfallbackPermissionSet: nullmust grant its users a permission set before upgrading, and an app-declared anonymous endpoint (authRequired: false) can no longer read or write objects: no supported channel grants anonymous callers a permission set yet (#21158, open). - Field-level security reaches every query door — cross-field comparands,
the activity stream, the compliance ledger, approval snapshots and analytics
(
de8cd58, #20954;1ecb871, #21179;1571aed, #20931). Andsys_audit_log/sys_activityserve a non-system reader only rows about records that reader can read, administrators included (30c530e, #21194;6f57888, #21069). - Stored metadata bodies are redacted wherever they are copied — the data
door, audit copies, realtime events and MCP stdio (
cfad7de, #21115;336e191, #21144;3ddd3d0, #21228). Runos migrate audit-metadata-bodies --applyto rewrite the copies already at rest. Reads ofdatasourceandexternal_catalogmetadata, and writes ofdatasourcemetadata, on/api/v1/metanow needmanage_platform_settings(454bbb6, #21148;7a606a9, #21119). - Analytics is judged like the data door — anonymous
401, hidden and masked fields403, related objects admitted and row-scoped, structured, multi-value and type-mismatched members refused400. A cube member'ssqland a dataset member'sfieldmust name a column (5d5e679, #20998;434c6c7, #21240): ⚠️CASE WHEN, aggregates and ratios are refused at parse,tscdoes not catch them, and there is no mechanical conversion. - The engine refuses what it used to answer wrongly.
aggregaterefuses a group, count or sum over a field type it cannot answer consistently, and filter shapes that returned every row, no rows or a driver's500now answer400. 「is empty」 lowers to the new$emptyoperator, so ⚠️ a sharing rule or view using it on a text or multi-value field also matches''and[](f1e921a, #20570). - Temporal values follow one rule at every door: a
datetimenames a UTC year from 1000 to 9999, atimeis a zone-less wall clock, and an import readsdate/datetime/timecells only in ISO 8601, the export's own shape or a year-first date (05a7547, #20843;63bfe69, #20721;eb4b17c, #20601). - One rule decides which flow is packaged. A packaged flow wins over a
stored flow of the same name at every startup step (
75519e1, #20942), so ⚠️ a stored flow that shares a packaged flow's name stops running. Flows saved through/api/v1/automationsurvive a restart (cb4c31d, #20907), and the on/off toggle switches only packaged flows (c8111a5, #20780). - Connectors keep only what runs:
triggers,syncConfigandfieldMappingsare retired (542670d, #20587;0efbdc3, #20903), and amappinggainsconnectorSource, which the automation service can pull through the import runner (8368f1c, #21084). - Tenant isolation on the drivers. Turso's remote face — every hosted
tenant database — applies the caller's tenant scope (
4b59a38, #21245), and a tenant-scopedupsertnever merges into another organization's row (95e24b0, #21225). The Turso remote transport issuesauto_numbervalues (e35c40a, #21160), and MySQL storessys_jwksandsys_memberrows (95b91cc, #21272). - Shared picklists: a new
picklistmetadata kind that fields reference by name and other packages can extend (addbbf0, #20823;88b484e, #21047). - Console: three objectui pin moves —
dd3f7e1be356 → db11afd4967c → e420df310f5b → 31971ff1e28f(a3d7588,b8191f7,0d42104) — carrying 232 releasing objectui changesets, 23 of them declared breaking upstream. The last restores Studio's spec-derived forms. ⚠️ Three Studio saves are refused by 17.6.0 server changes at this pin; see Known console issues.
What's new in 17.6.0
17.6.0 moves the whole version-locked train and no major; the runtime still
implements protocol 17. It is compiled from the 338 changesets pending on
main at 748b2407. Sixteen of them describe code that 17.5.0 already
shipped — see Shipped in
17.5.0 — so 322 are new in
this release. The bundled Console advances three pins,
dd3f7e1be356 → db11afd4967c → e420df310f5b → 31971ff1e28f.
⚠️ Read this before treating the version number as a safety guarantee. As
with every minor of this line, entries that landed after the 17.0.0 cut ship as
minor (or patch) under the lockstep launch-window convention while being
explicitly breaking. Several things in this release change behaviour on a
running deployment with nothing to parse-fail on:
- a caller that carries a principal but resolves no permission set is refused
every object, and served fields gated by
requiredPermissionsor amaskingRulemasked or not at all; sys_audit_logandsys_activityserve non-system readers, administrators included, only rows about records they can read — everydeleterow and sign-out row drops out of the data API;- stored metadata bodies are redacted on the data door, in audit copies,
realtime events and MCP stdio, and reading
datasource/external_catalogmetadata (or writingdatasource) on/api/v1/metaneedsmanage_platform_settings; - analytics widgets that read a hidden or masked field, or a related object the
caller may not read, answer
403; a time dimension that declares one granularity is bucketed by it; - the engine's
aggregaterefuses groups and aggregates over field types it cannot answer, and filter shapes that used to return wrong rows answer400; - 「is empty」 in a stored sharing rule or view also matches
''and[], so such a sharing rule shares more records; - a
datetimewrite before year 1000, atimevalue with an offset, and an import date cell that is not ISO 8601, the export's shape or year-first (07/15/2026, an Excel serial) are refused; - a caller-supplied
formulavalue is stripped from every write; - a stored flow that shares a packaged flow's name stops running; the toggle
refuses customer-authored flows with
409; enabling a packaged flow whose packaged subflow is disabled is refused; - an
httpnode whose non-emptysigningSecretrenders to nothing at run time fails the node instead of sending unsigned; - remote Turso scopes reads and writes to the caller's organization, and a
cross-organization
upsertanswers409; - a stored Turso datasource that forces
mode: 'local'beside asyncUrlno longer builds, and a bound one fails the boot; - a first
os plugin publishwithout--visibilitymakes the packageorg, notprivate.
Breaking changes & migration in 17.6.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. Everything else is left to the per-package
CHANGELOG.md files. The three Console pin refreshes are described once under
New in Console rather than
enumerated here.
The retirements in this release are registered as ADR-0087 conversions under
protocol major 18, as in 17.5.0. os migrate meta --from 17 lists the
source edits, and os migrate meta --stored --apply rewrites stored rows where
a lossless conversion exists. The new conversions are
action-block-endpoint-to-target, connector-sync-keys-removed,
connector-triggers-removed, cube-refresh-key-removed,
dataset-count-measure-empty-field-removed, form-field-public-picker-removed,
form-view-subform-columns-canonicalized, page-header-breadcrumb-removed and
time-default-utc-suffix-dropped. Most breaking changes below have no
mechanical rewrite; each says so. An app keeps engines.protocol: '^17'.
A caller that resolves no permission set gets the deny baseline (#21217, #21134, #21051)
A non-system caller that carries a principal — a position, a named permission
set or a user id — but resolves no permission set used to be admitted to
every object no set grants, for reads and writes, with only the record-sharing
predicate as its row scope. It also received every field: a field declaring
requiredPermissions or a maskingRule was served as stored and could be
filtered, sorted, grouped, aggregated and written.
- Objects (
62b90d7, #21217). Every engine operation for that caller now answers403 PERMISSION_DENIED.ISecurityService.canReadObject,canExportand the write preview answerfalse, andgetReadFilterreturns the deny filter. - Fields gated by
requiredPermissions(665cab3, #21134). The field is not served (or is served masked, if it also declares amaskingRule). A filter, sort key, group key, aggregate or write payload naming it is refused403 PERMISSION_DENIED.getQueryableFieldsandgetWritableFieldsno longer list it;getReadableFieldslists it only when amaskingRuleserves it masked, andgetMetadataReadableFieldsdrops it when the deployment's fallback set resolves to nothing. - Fields with a
maskingRule(a9d36d5, #21051). The field is served masked and is not queryable. A write that sends the masked placeholder back is refused400 VALIDATION_ERROR.
This reaches unauthenticated guest-envelope requests on deployments that grant
anonymous callers no set, contexts that name only unregistered sets, and
signed-in users on an embedder that sets fallbackPermissionSet: null.
Principal-less contexts, system contexts, the public form submit and signed-in
users of a stock objectstack serve are not affected.
Migration.
- App-declared anonymous endpoints (
authRequired: false) can no longer read or write objects until theguestanchor's bindings resolve for anonymous callers (#21158). That issue is open, and until it lands no administrator binding grants an unauthenticated caller a permission set, so there is no supported migration: an app that depends on such an endpoint reading or writing objects should hold the upgrade. - An embedder that sets
fallbackPermissionSet: nullmust grant signed-in users a permission set explicitly. - A caller that needs a gated field needs a permission set holding all of the
field's
requiredPermissions; otherwise drop the requirement or themaskingRulefrom the field. ISecurityServiceimplementers must answerfalsefromcanReadObject,canExportand write admission, return the deny filter fromgetReadFilter, and foldrequiredPermissionsinto field answers for such a caller. The@objectstack/spec/contractsdocblocks now state this.
Field-level security reaches every query door
A field the caller may not read, or reads masked, could still be used to
select rows on several doors, so which rows matched disclosed its value. Each
of these doors now refuses the query with 403 PERMISSION_DENIED:
- Cross-field comparands (
de8cd58, #20954). A field named as the right-hand side of a comparison (FieldReferenceSchema) inwhere,havingor an aggregationfilteris judged with the filter, sort, group and aggregate fields, onfind,findOne,count,aggregateand the bulkupdate/deletepredicate. An invalid comparand that names a hidden field may now answer403rather than400 INVALID_FILTER. - The activity stream, the compliance ledger and approval snapshots
(
1ecb871, #21179). A filter, search, sort, group or aggregate onsys_activity's value-bearing columns,sys_audit_log's before/after snapshots orsys_approval_request's snapshot is refused unless the caller is served every field of the parent object named by equality at the root of the filter — or, when none is named, of every registered object. - Analytics — field reads and masked fields are refused on every analytics door; see Analytics.
Approval payload snapshots also stop serving, as stored, a field the reader is
served masked on the data plane; the field and its derived labels are dropped
from both read doors (2f2fa11, #20993). The anonymous public-form submit
passes its 201 echo through the result masker, so a masked field — including
one filled from a defaultValue the form never shows — comes back masked
(9c8b65a, #21101).
Migration. Grant the field's read permission (or every capability its
requiredPermissions names) to the users who need the query, or rewrite the
query on fields they can read. To filter a gated activity, ledger or approval
column, name one parent object by equality at the root of the filter (or inside
a root $and). A host that builds ApprovalService with its own
fieldVisibility source must add getQueryableFields to it; without it, no
snapshot field is served.
The compliance ledger and the activity stream serve only rows about records the reader can read (#21194, #21069)
A non-system read of sys_activity (6f57888, #21069) and of sys_audit_log
(30c530e, #21194) now returns a row only when the caller's own engine read of
the record it names (object_name, record_id) succeeds. Sharing, RLS and
object permissions decide. The gate is engine middleware on find, findOne,
count and aggregate, so it covers the list, its total, the by-id read and
both query shapes. Admins are included; only system context is exempt.
No longer served to non-system callers:
- rows about records the caller cannot read, and about deleted records — so
every
deleterow in the ledger; - sign-out rows, and sign-in rows whose session was removed;
- rows naming no record or an unknown object, and rows naming the ledger or the activity stream itself.
config_change rows, the run-level user-import row, platform-admin standing
rows and auth events without a session id are still served under the ledger's
grant. A pre-scan that reaches its 2,000-row bound fails closed with a warning.
Rows stay stored.
The ledger rows written by the admin create-user and set-user-password
endpoints now record only the admin's decisions in metadata, never values of
the user's fields (55012df, #21195); rows written before this release keep
the copies they carry. Auditors who must see every snapshot field need a
permission set that unmasks those fields (fbcc05f, #21171).
Migration. No metadata or code change. Views and reports that list
deletions or sign-outs from sys_audit_log through the data API will show
fewer rows; a server-side job that must read every row must read under system
context. A reader that took a user field's value from the admin endpoints'
metadata must read the mirror row's after-snapshot for the same write.
Stored metadata bodies are redacted wherever they are copied (#21144, #21115)
A sys_metadata or sys_metadata_history row stores the full metadata body,
including a datasource body's credential material. That body was served as
stored on several exits. It is now projected through the same redactor the
/meta exits use:
- the generic data door (
cfad7de, #21115):GET /api/v1/data/:object,POST /api/v1/data/:object/query, the by-id read, and the export route. A body whose row has notype, or that fails to parse while its type registers a redactor, is omitted.?select=metadatastill works. - audit copies and realtime events (
336e191, #21144):sys_audit_log'snew_value/old_value,sys_activity.metadata, and theafter/changesofdata.record.*events for these objects. - the MCP stdio transport (
3ddd3d0, #21228): its query and get verbs and its record resource.
The body column is also refused as a data-door filter, sort or groupBy entry,
as an MCP stdio group, filter, sort or aggregate member, and as an analytics
dimension, measure, filter or sort, with 400 INVALID_FIELD.
Migration. Group, filter and sort these tables by type, name or another
scalar column. Copies already at rest are not rewritten by the upgrade. Run
os migrate audit-metadata-bodies to see them (it is a dry run by default),
then os migrate audit-metadata-bodies --apply to rewrite them; the command is
idempotent.
Datasource metadata needs manage_platform_settings on /api/v1/meta (#21148, #21119)
The datasource admin door (/api/v1/datasources) already required
manage_platform_settings. The generic /api/v1/meta door did not, so a
caller holding only an authoring capability could read and write the same
definitions there.
- Writes of
datasource(454bbb6, #21148):PUT /meta/datasource/:name(draft saves included),DELETE,/publishand/rollback, plural spellings included, now answer403 PERMISSION_DENIEDnaming the capability. The authoring admission (manage_metadata) still applies, so both are needed. - Reads of
datasourceandexternal_catalog(7a606a9, #21119): every read route — list, item,/published,/layers,/history,/audit,/diff,/references,GETandHEAD— answers the same403, whether or not the item exists.
admin_full_access holds both capabilities, so platform administrators are
unaffected.
Migration. Grant manage_platform_settings, through a permission set, to
every user or integration that reads either type, or writes datasource,
through /api/v1/meta while holding only
manage_metadata, studio.access or setup.access — or route those calls
through a caller that already holds it.
Analytics judges, admits and types like the data door
17.5.0 made the analytics routes check the object read grant. 17.6.0 carries
the rest of the data door's rules onto POST /api/v1/analytics/query,
/analytics/sql and /analytics/dataset/query, on every driver and both
strategies.
Who may read what
- Anonymous callers get
401on the runtime dispatcher's analytics faces — the cube read, the SQL echo and meta — before the service lookup or body validation (2bddb19, #21098). - Field-level read permissions are checked before a strategy is chosen
(
1571aed, #20931). Dimensions, measures, time dimensions, filter members, order keys, joined members and a dataset's own and requested measures' filters are judged; a hidden one answers403 PERMISSION_DENIED. Before, the native-SQL strategy answered such queries. - Masked fields cannot be grouped, aggregated, filtered or sorted
(
83480c6, #20955); they answer403. If the security service cannot say which fields are queryable, every field declaring amaskingRuleis treated as not queryable. - A related object reached through a relationship path is admitted and
row-scoped like a declared join (
5f6b63a, #20962). A related object the caller may not read answers403naming it, and its row scope applies on native SQL too. Each hop of a multi-hop path is judged. - A hop the cube declares no join for reads the object its lookup
references (
9b81314, #21088), not an object that happens to share the field's name. Where those differ (fieldaccountreferencingcrm_accountwhile an objectaccountexists), the path now reads the declared target. - The nested-relation filter
{ relation: { field: value } }runs through the data engine (8d329f0, #20916), as the caller, with its row scope, field permissions and 1,000-record cap. A condition on an unreadable related field answers403; more than 1,000 related matches, the form inside a measure's ownfilter, or the form on/analytics/sqlanswers400 INVALID_FILTER. - Objects whose read gates live in engine middleware leave the native-SQL
strategy (
cb45469, #21170). On the stock composition that movessys_comment,sys_activity,sys_attachment,sys_approval_request,sys_user_positionandsys_permission_setonto the ObjectQL strategy, so a query it cannot serve (such as a relationship-path dimension withavgorcount_distinct) now gets its400. - Row policies and native-SQL
whereclauses read$contains/$notContainson a multi-valued or JSON-stored field as list membership (58a77db, #21117), as the data door does. Before, a compiled read scope matched the stored JSON text as a substring: on SQLite a policy could admit rows outside it, and on PostgreSQL every query under it answered500. - A dataset
fieldthat is not a column reference is refused at the dataset door, for every caller and with or without a security service (ce4e205, #21190). Since434c6c7(#21240) the door parses the dataset first and answers400 VALIDATION_FAILED; the403remains only for a stored row that reaches the service without that parse.
What may be grouped, counted and aggregated
- A dimension over a structured-JSON field (
json,composite,repeater,record,location,address,vector) is refused400 INVALID_FIELDbefore any SQL is built (00a92e1, #20886). Before, SQLite answered one group per serialized document and PostgreSQL answered500. - A dimension over a multi-value field, and
count_distinctover a JSON-stored or multi-value field, are refused the same way (bb2eccf, #21019), including through a relationship path the cube declares no join for (4727fcb, #21247). A dataset pairingcount_distinctwith amultiple: truefield on its base object is refused400 DATASET_INVALIDat registration, so such a stored dataset no longer registers. - Every cube measure is checked against the aggregate × field-type table
(
39ab294, #21128), and so is a measure over a relationship path such asaccount.name(3a7b6eb, #21230).min/maxover a non-numeric, non-temporal, non-boolean type, andsum/avgover a type outside the numeric and boolean classes (orsumoverpercent), answer400 INVALID_FIELDinstead of a raw value typednumber, a0or a500.min/maxoverdate,datetimeortimeis now describedfields[] { type: 'time' }, notnumber, and a related numericmin/maxon PostgreSQL returns a number instead of an exact-decimal string.
What a cube means
- An authored cube's measure
formatand single time-dimension granularity now take effect (c8dd8dd, #20635). Every measure column carries its declaredformatinfields[]. A time dimension that declares exactly one granularity (granularities: ['month']) is bucketed by it when a query groups by it without stating one. Because the raw-SQL path declines bucketed queries, such a query now runs on the engine-aggregate path and answers400 INVALID_FIELDfor members that path cannot evaluate. On a host that offers raw SQL only, every newly bucketed query answers "No strategy can handle query".
Migration.
- If a widget stops answering for some users, it reads a field, a masked field or a related object they may not read. Grant them read on it, or build the widget on fields and objects they can read.
- Group by a field that stores one scalar value: store the part of a document
you group on in a field of its own. For a multi-value field, run a record
query filtered by one member with
$contains, one query per member. - Aggregate a field of a type the aggregate accepts; use
count(orcount_distinctover a scalar field) to count. Readers that branch onfields[].type === 'number'for a temporalmin/maxcolumn must accepttime. - Move a nested-relation condition out of a measure's
filterinto the query'swhere, and use/analytics/queryrather than/analytics/sqlfor a query that carries it. - For a time dimension with one declared granularity whose queries now fail or
change shape, query without grouping by it, or declare two or more
granularities (or none). Name another granularity in
timeDimensionsto bucket differently. - A policy or dashboard filter that relied on the substring reading of a multi-valued field now selects members only.
- Custom hosts only. A host that constructs
AnalyticsServiceitself passesgetReadableFields,getQueryableFieldsand (withexecuteRawSql)hasObjectMiddleware;AnalyticsServicePluginwires all three on the stock composition. A non-ObjectQLdataservice should implementhasObjectMiddleware. A customsqlDialecthook must answer'sqlite','postgres'or'mysql'for every SQL datasource. A host that offers raw SQL only must add the engine aggregate bridge. Callers ofcompileDatasetpassdeclaredValueShapeinstead ofdeclaredFieldType.
Cube and dataset members name columns, not SQL (#20998, #21240, #20710)
- A cube member's
sqlmust be a column reference (5d5e679, #20998): a field of the cube's object (amount), a relationship path of bare identifiers (account.owner.region), or'*'for a count.CASE WHEN, aggregates, ratios of aggregates, quoted or$-prefixed spellings and empty strings are refused at parse bydefineCube(),defineStack({ analyticsCubes })(STACK_SCHEMA_INVALID/ 422) and theanalytics_cubewrite door. On 17.5.0 an expression ran verbatim on the raw-SQL strategy and was refused on the ObjectQL strategy.tscdoes not catch it — the type is stillstring— and there is no mechanical conversion. - A dataset dimension's or measure's
fieldmust be a column reference (434c6c7, #21240), by the same rule; measures also accept'*', and a count may omitfield.DatasetSchema,defineStack({ datasets }), thedatasetwrite door andPOST /api/v1/analytics/dataset/queryrefuse an expression (the query door now answers400 VALIDATION_FAILEDwhere it answered403). A stored count measure written asfield: ''— the shape Studio's dataset inspector seeds — is repaired on load by the conversiondataset-count-measure-empty-field-removed, but a new save of that shape is refused. - A cube's
refreshKeyis retired (1ab9892, #20710). No analytics result is cached, so the declared cadence refreshed nothing.tsctypes itnever, the parse doors refuse it, and the conversioncube-refresh-key-removeddeletes it from built artifacts and stored rows. os validate,os build,os lintand runtime dataset saves refuse a dimension over a JSON-stored or multi-value field with the new ruledimension-json-stored-field-refused, andmeasure-aggregate-field-type-refusednow also refusescount_distinctover amultiple: truefield (5e470f8, #21073). The analytics door already refused both at query time.
Migration. Replace each refused sql or field with the column it reads.
Move a derived value to a dataset over the same object:
| you wrote | write instead |
|---|---|
a member sql: "CASE WHEN status = 'won' THEN amount END" with type: 'sum' | a dataset measure { name: 'won_amount', field: 'amount', aggregate: 'sum', filter: { status: 'won' } } |
| a conditional count | a count measure with its own filter |
| a ratio, sum, difference or product of two measures | a derived: { op, of: [...] } measure |
a dimension that bucketed with CASE | group by the column itself, or by a stored bucket field |
refreshKey: { every: '1 hour' } | delete it |
a count measure with field: '' | omit field |
Point dashboards and queries that named <cube>.<member> at the dataset
measure. A derived ratio is a 0–1 fraction, so pair it with a % format
(for example 0.0%) and re-check consumers that read a ×100 percentage.
os migrate meta --from 17 lists the refreshKey and empty-field edits;
nothing else here has a mechanical rewrite.
The engine's aggregate judges what it is asked to group, count and sum
The engine's aggregate answered some pairs differently on every driver — one
merged group in memory, one group per serialized document on SQLite, 500 DATABASE_ERROR on PostgreSQL. It now refuses them with 400 INVALID_FIELD
before any driver is asked. The error names the position (groupBy[0],
aggregations[0].field) and carries field, fields, object and param.
groupByon a structured-JSON field —json,composite,repeater,record,location,address,vector(157baa7, #20804).groupByon a multi-value field —multiselect,checkboxes,tags, or aselect/radio/lookup/user/file/imagedeclaredmultiple: true— andcount_distinctover a JSON-stored or multi-value field (975b248, #20911).min/max/avgover a type the aggregate × field-type table refuses (a75311d, #21037).min/maxacceptnumber,currency,percent,rating,slider,progress,summary,date,datetime,time,booleanandtoggle;avgaccepts the same minus the three temporal types.sumover a type the table refuses (d98bf24, #21103).sumnow accepts onlynumber,currency,rating,slider,progress,summary,booleanandtoggle. Before,sumoverjson,text,selectortagsanswered0,datetimeadded years on SQLite, andpercentadded rates.
These verdicts reach the REST query door, flows and hooks, roll-up summary
recomputes (a summaryOperations recompute that hits one records a failure),
grouped list-view header summaries and chart or metric aggregates.
Migration. Group by, and aggregate, a field that stores one scalar value of
a type the function accepts. Use count (or count_distinct over a scalar
field) where you were counting; sort a list for the first or last record by a
text value; average a percent instead of summing it; store a formula result
in a numeric field of its own to sum it on the server. For a multi-value
field, run one query per member with $contains. Check summaryOperations,
grouped list-view summaries and chart / metric aggregates for such pairs. These
are the hand-migrations already registered under
dataset-measure-aggregate-field-type-refused and
dataset-measure-selecting-aggregate-field-type-refused.
A filter is refused where it used to answer the wrong rows
Each of these used to return every row, no rows, a text comparison or a
driver's own 500. Each now answers INVALID_FILTER / 400 — at where, a
per-aggregation filter and having unless the item names its positions:
- An object with no
$operator under a scalar field ({ "amount": { "a": 1 } }, or{}) (97005ae, #20744), and under a structured-JSON field or an undeclaredid/created_at/updated_atcolumn (4b4ee88, #20781). Relation fields keep the nested-relation form inwhere; in a per-aggregationfilterorhavingthat form is now refused too. - A temporal comparand the write door would refuse (
2473e26, #20668): a day that does not exist ("2026-02-30"), adatetimeoutside the ISO 8601 spellings the platform writes ("07/15/2026 10:00", an offset after a space, a bare integer string such as"2026"), and the same classes on atimecolumn. Before, these answered host-zone-dependent rows or text comparisons. - A
datetimecomparand before year 1000 — see Temporal values. - A relative-date placeholder that resolves outside its field's years
(
dcd3309, #21065) or past what a JavaScriptDatecan hold (1bd14c9, #21123), such as{300000_years_ago}. $startsWith,$endsWith,$icontains,$likeor$ilikeon a JSON-stored or multi-value field, in the SQL drivers'where(Turso's local transport included) and the per-aggregationfilter(2c1cef3, #21165). SQLite matched the serialized text ($startsWith: "["matched every valued row) and PostgreSQL answered500.$eq,$ne, orderings,$between,$in,$ninor implicit equality on a JSON-stored field inside a per-aggregationfilter(a11faee, #21097), aswherealready refused.{ owners: { $in: ['u1', 'u9'] } }counted0, and$nincounted the rows it was asked to exclude.- A non-boolean
$existsin the in-memory and MongoDB drivers, which read anything buttrueas "has no value" (a3dc817, #20979), and a non-boolean$existsor$nullin a per-aggregationfilterorhaving, where"yes",1and the string"false"were read by truthiness (c35436c, #21157).
Migration. Compare a field with a value or an operator; to filter by a
related record, name a relation field. Send temporal comparands in ISO 8601
(YYYY-MM-DD, or YYYY-MM-DDTHH:MM[:SS[.fraction]] with Z, an offset or
nothing) and epoch milliseconds as a JSON number. For membership on a
multi-value or JSON field, write { "FIELD": { "$contains": VALUE } }, an
$or of $contains for any-of, and $not around either for exclusion. Write
$exists / $null as booleans. Use relative-date placeholders that land
inside the column's years. Review saved filters, list views, dashboard widgets
and reports that use any of these shapes; there is no mechanical rewrite.
「is empty」 also matches empty text and empty lists (#20570)
「is empty」 / 「is not empty」 — is_empty, isempty, is_not_empty,
isnotempty in view rules, sharing rules and filter arrays — now lower to the
new $empty operator instead of $null (f1e921a, #20570). $empty answers
by the field's declared type, so a text field also matches '' and a
multi-value field also matches []. Stored views and sharing rules are not
rewritten; they are re-read under the new meaning, so rules on text or
multi-value fields match more rows after the upgrade.
Newly refused: a { $empty: … } object written as a field value
(VALIDATION_FAILED, invalid_type), and 「is empty」 where no declared type
is available — the built-in id, a federated object on a driver without
registerExternalObject (driver-memory, driver-mongodb), an
AnalyticsService built without sourceFieldMeta, or a multi-value column on
a SQL dialect other than SQLite, PostgreSQL or MySQL.
Migration. Review sharing rules and views that use 「is empty」 on text or
multi-value fields; a sharing rule that now matches '' rows shares those
records too. Use is_null / is_not_null for the built-in id and where no
declared type is available. Write values, not filters, in insert and update
payloads.
Temporal values are held to one rule at every door
- A
datetimevalue must name a UTC year from 1000 to 9999 (05a7547, #20843), as a written value and as a filter comparand; adatekeeps 0001 to 9999. The year is the instant's UTC year, so1000-01-01T00:00:00+08:00is refused. MySQL documentsDATETIMEfrom year 1000 and reads 0001–0099 back a century late. Stored values before 1000 are not rewritten and read back as before, but a write that carries one is refused. - A
timevalue is a zone-less wall clock. Atimefield refuses a value carryingZor an offset ("10:00Z","10:00+08:00") and an instant whose UTC year has no four-digit spelling (63bfe69, #20721), withVALIDATION_FAILED/400(invalid_time). A zone-suffixedtimefield default ortimeaction-param default is refused when the schema parses it, and a submittedtimeaction param with a zone answersinvalid_shape(c9c182e, #20763). On memory and SQLite, zone-suffixed values used to be stored verbatim and read back differently. POST /api/v1/data/:object/importreadsdate,datetimeandtimecells only in ISO 8601, the export'sYYYY-MM-DD HH:mm:ssshape or a year-first date, on days that exist (eb4b17c, #20601). Before, cells went to the JavaScript date parser, which read them in the server's timezone and month-first and rolled impossible days into the next month, so the import reported success and stored a different value. Anything else (07/15/2026,2026-02-30,15 July 2026, an Excel serial) now fails its row withinvalid_date, and the rest of the batch still imports.timecells follow the write door's rule, keep their milliseconds, and reportinvalid_timewhen refused (22e584c, #20829).- A whole-day upper bound on
9999-12-31includes that whole day on every backend (1a75e39, #20643). Before,$lte '9999-12-31'returned no rows on SQLite.nextUtcCalendarDay('9999-12-31')now returns the newUNBOUNDED_ABOVEsymbol instead of'10000-01-01', exported withUnboundedAboveandisUnboundedAbovefrom@objectstack/spec/dataand@objectstack/core. TypeScript code that uses the answer as a string stops compiling, and JavaScript that treats it as text throws.
Migration.
- Find stored
datetimevalues before year 1000 with$lt '1000-01-01T00:00:00.000Z'and rewrite them (or set them tonull) before anything writes the row; there is no registered conversion. - Write
timevalues as a bare wall clock ("10:00"). The conversiontime-default-utc-suffix-droppeddrops aZor zero offset from storedtimedefaults when they load. A non-zero offset is left as stored and listed as a TODO byos migrate meta --stored; rewrite it by hand to the wall clock you meant, or use adatetimefield. - In code that calls
nextUtcCalendarDay, test the answer withisUnboundedAbove(answer)before using it as a day string, and compile no upper bound on the true branch.
Writes and validation
- Caller-supplied values for
formulafields are stripped on every engine write and reported withreason: 'computed'(b280546, #20834). Before, a formula value from a form save, a flowupdate_recordor a GET-then-PUT failed the whole write on SQL drivers and was stored as an unread shadow column in memory.beforeInsert/beforeUpdatehooks no longer see the key inctx.input.data(ctx.submittedstill carries it on update), andstrictReadonlyWritesrefuses a formula value withERR_READONLY_FIELD_REJECTED.ObjectQL.validate— and so the protocol'svalidateDataand the import dry run — now runs the write's own field doors, so a row carrying a key the object does not declare answersINVALID_FIELD/400instead ofvalid: true. MigrationFlagEngineandSeedTenancyLedgerrequirefindOneinstead offind(cd6d8a5, #20766). The platform now readssys_migrationrows by primary key, which ends the "Paged read of 'sys_migration' is NOT deterministic" warning that every boot printed on a database created by 17.4.0 (#20648). A hand-written stand-in for these helpers must implementfindOne; a host that passes the ObjectQL engine needs no change.
Migration. Callers of ObjectQL.validate must expect INVALID_FIELD for
an undeclared key, and any exhaustive branch on DroppedFieldsEvent['reason']
must handle computed. Stop sending formula values when you pass
strictReadonlyWrites. A beforeUpdate hook that read a submitted formula
value from ctx.input.data reads ctx.submitted.
Flows: one rule decides which flow is packaged, and the /automation doors persist what they save
- The package loader alone decides whether a flow is packaged (
76bd58f, #20853). A definition's own provenance is now display-only. Every flow write through an authoring door is judged as deployment-authored: writing a shipped flow's name answers403 NOT_OVERRIDABLE, and a definition that claims a code package's provenance for a name no package ships answers422 INVALID_METADATAwith nothing written. Boot-time precedence reads the same set (27bf358, #20880).PUT /api/v1/automation/:name, aPOST /api/v1/automationonto an existing name andDELETE /api/v1/automation/:namerefuse a packaged flow with the same403as the metadata door (4b45afa, #20817). - A flow save that names a package this deployment has not installed is
refused with
422 WRITABLE_PACKAGE_REQUIREDonPUT /api/v1/meta/flow/:name(b1aee33, #20959). It used to answer200and store the flow live, bound to a package that does not exist. - A packaged flow wins over a stored flow of the same name at every startup
step (
75519e1, #20942). Thekernel:readybind used to register flows with no precedence, so a stored same-named flow could be armed after the package's flow and then run, whilegetShadowedFlows()said otherwise. ⚠️ A stored flow that shares its name with a managed package's flow is now shadowed and no longer runs. POST /api/v1/automationandPUT /api/v1/automation/:namesave a metadata row that survives a restart (cb4c31d, #20907), asPUT /api/v1/meta/flow/:namealready did, and refuse what the metadata store refuses. A flow created throughPOST /automationused to be gone after a restart.DELETE /api/v1/automation/:namedeletes the row. Newly refused: a flow name with a leading underscore (400 INVALID_REQUEST) and definitions a gating publish rule refuses.- The toggle switches only packaged flows (
c8111a5, #20780).POST /api/v1/automation/:name/toggleandclient.automation.togglerefuse a customer-authored flow with409 RESOURCE_CONFLICT; a customer flow's own switch is its definitionstatus. - Subflows and their callers (
679f95e, #20711;0d9349f, #20759;36d043b, #20724). Enabling a packaged flow is refused with409while a packaged subflow it calls (through asubflowormapnode) is disabled; on 17.5.0 the enable was accepted and every run failed at that node withFLOW_DISABLED. A caller in that state is left unarmed on every door — create, republish, upgrade, reload and boot — andGET /api/v1/automation/_statusreportsenabled: true, bound: falsewith a reason. Removing a packaged subflow that its callers can still reach is refusedDELETE_RESTRICTED/409. Disabling a packaged subflow is now allowed once its packaged callers are switched off and hold no parked run. - An
activesave of anapi-triggered flow with no usableconfig.secretis refused at the metadata door (31ed067, #20692) with422 INVALID_METADATA(flow-api-trigger-secret-missing);os validaterefuses it too (e651556, #20593). 17.5.0 stored such a flow and then declined to register it. A secret withheld on read and restored from the stored row counts as present, so GET → edit → PUT keeps working.
Migration.
- Customize a packaged flow by cloning it under a new name (or switching it
off), not by storing a flow with its name. Check startup warnings or
getShadowedFlows()for stored flows that share a packaged flow's name; after the upgrade they no longer run. - If a flow write answers
422 INVALID_METADATAfor its provenance, remove the package provenance from the definition and send it again. If it answers422 WRITABLE_PACKAGE_REQUIRED, save it into an installed package (create a new base through the package door first) or name no package. - To switch a customer-authored flow off, send
PUT /api/v1/automation/NAMEwith the complete definition andstatus: 'obsolete'('active'to re-arm); in the SDK,client.automation.toggle(name, false)becomesclient.automation.update(name, { ...definition, status: 'obsolete' }). ⚠️ A customer flow that the toggle already switched off before this release stays held off by its ledger row, which nostatusclears: clone it withPOST /api/v1/automation/NAME/clone(the copy is armed), then remove the original. - Enable packaged subflows before their callers; scripts that toggle flows on
must order the calls child-first. To remove a packaged subflow, disable its
enabled callers, switch the subflow off (cancelling any parked runs the
refusal names with
POST /api/v1/automation/:name/runs/:runId/cancel), then remove it. - Rename flows whose name starts with
_. - Give every
apiflow's start node a non-blankconfig.secret, or declare a flow that is only started explicitlytype: 'autolaunched'with notriggerType: 'api'on its start node. - Code that builds an
AutomationEnginewithoutAutomationServicePluginmust attach a reader withsetPackagedFlowSource(reader); code that callsresolveFlowPrecedenceordescribeFlowContenderitself passes(name) => engine.packagedFlowOwner(name)as the last argument.
Connectors keep only what runs (#20587, #20903)
A connector's triggers, syncConfig and fieldMappings were parsed and
stored, but nothing ever polled a trigger, received a webhook or ran a sync.
All three are now refused at parse with a prescription — by defineConnector,
stack.connectors[], PUT /api/v1/meta/connector/:name and
AutomationEngine.registerConnector — and typed never in tsc
(542670d, #20587; 0efbdc3, #20903). ConnectorTriggerSchema,
DataSyncConfigSchema, SyncStrategySchema,
ConnectorConflictResolutionSchema, ConnectorFieldMappingSchema and their
types leave @objectstack/spec/integration. Runtime behaviour does not
change. A mapping gains connectorSource to declare a connector pull.
Migration.
| you wrote | write instead |
|---|---|
a triggers entry with type: 'polling' | a schedule flow whose connector_action node calls the connector's action at that cadence |
a triggers entry with type: 'webhook' | an api flow the sender calls, with a connector_action node (it needs a per-flow secret and signed calls) |
syncConfig + fieldMappings | a mapping on the target object with connectorSource (connector, read action, watermark for an incremental pull); carry each fieldMappings source → target pair into the mapping's fieldMapping (a defaultValue becomes a constant transform) |
direction, conflictResolution and deleteMode have no counterpart: the pull
is one-way and writes through the mapping's mode / upsertKey. In 17.6.0
nothing schedules a pull yet — AutomationServicePlugin.pullConnectorSource
runs one when your code calls it (see New
capabilities), and a job that drives it is
planned.
os migrate meta --from 17 lists the source edits, and the conversions
connector-triggers-removed and connector-sync-keys-removed strip the keys
from stored connector rows on load.
Drivers
- Turso's remote face applies the caller's tenant scope (
4b59a38, #21245). Onlibsql://andhttps://— the transport every hosted tenant database uses — reads, updates and deletes now carry the same organization predicate the local face compiles, andcreatestamps the caller's organization. Before, the remote doors received no driver options and carried only the caller's filter, so where the engine's own tenant wall composed no predicate, nothing scoped the statement. - A tenant-scoped
upsertnever merges into another organization's row (95e24b0, #21225). On the SQL drivers and Turso, a conflict on another organization's row (or an organization-less one) answers409 UNIQUE_VIOLATIONand writes nothing, and an upsert never changes a row's organization. - Remote Turso refuses JSON-incompatible operators on JSON-stored fields
and answers
$contains/$notContainsby membership (862f12c, #21208), as the SQL drivers already did; over amultiple: truelookup its$nin/$nematched every row and$contains: 'u1'matched a row holding onlyu10. The memory driver refuses the equality and ordering family there —$eq,$ne, orderings,$between,$in,$ninand implicit equality — where it used to answer$eqper element, so tests on it passed filters every SQL backend refuses (45ce12a, #21159). - MongoDB's
$contains/$notContainstest membership on JSON-stored fields, and so doesmatchesFilterCondition— and with it the RLS writecheck(e18fea6, #21196). Achecksuch asrecord.tags.contains('x')now admits a post-image['x'], and a lone scalartags: 'x'is judged as the['x']it is stored as (d2bc644, #21253). - A Turso config that forces
mode: 'local'beside a non-emptysyncUrlis refused at authoring and when the driver is built (05cb2bc, #20669). It used to run as a synced replica under alocallabel. A stored datasource row in this shape is not re-parsed on load, so the driver build fails and, for a bound or boot-critical datasource, the boot fails fast unlessOS_ALLOW_DRIVER_CONNECT_FAILUREis set. - Drivers no longer keep their own whole-day bound or
$notrewrite (ceee88f, #20988;53ed3d1, #21105;8460592, #20925). Reads through the engine and the RLS seam keep their whole-day answer on declareddatetimecolumns, because the sharedlowerFilterConditionlowers them first. On MongoDB and the memory driver, a bare-day bound on a column that is not declareddatetimeis now compared as written, as on SQL; an unregistered object, and an RLS policy compiled with no field guard, are lowered type-blind. A directSqlDriver,SqliteWasmDriver,TursoDriver,MongoDBDriverorInMemoryDrivercall now compares a bare-day$lteagainst midnight and treats$betweenas inclusive. TheprotectedmethodscalendarDayExclusiveUpperBound,calendarDayUpperBoundRewriteandcalendarDayBetweenRewriteare removed.
Migration.
- To read or write across organizations on remote Turso, or to create a row
with no organization, call without
tenantId. To move a row between organizations, useupdate(), not an upsert payload's tenant value. - Rewrite filters on JSON-stored fields as
$containsfor one member, an$orof$containsfor any-of and$notfor exclusion. A policy such as!record.tags.contains('x')now refuses a write oftags: 'x', which it used to admit. - For an embedded Turso replica, drop
mode(keepurlandsyncUrl); for a plain local database, dropsyncUrl. Fix stored datasource rows in this shape too. - A direct driver call that needs the whole-day reading lowers the filter
first:
driver.find(object, { where: lowerFilterCondition(where, { isDatetimeColumn }) }), withlowerFilterConditionfrom@objectstack/spec/data. A driver subclass that overrides one of the removed methods does the same.
Views, pages, forms and dashboards
- A view saved through the metadata door stores the parsed value of each key
its body carried (
9905e61, #20868), onPUT /api/v1/meta/view/:nameand the Studio and MCP saves. Undeclared keys are dropped (objectName, a sort row'sid), declared keys keep their normalized value (notEquals→not_equals), moved keys are stored canonically (groups→sections,visibleOn→visibleWhen), and defaults the author did not write are not stored. A ViewItem record ({ name, object, viewKind, config }) with a top-leveloptionsbag is refused422 INVALID_METADATA. Stored rows keep their bytes until their next save. - An html page's
sourceis compiled at the save door (b531c7b, #20852).os servenow registers the SDUI component manifest at boot — the project'ssdui.manifest.jsonbeside the config, else the copy@objectstack/consoleships — andPUT /api/v1/meta/page/NAMErefuses a component it does not declare, or a hand-writtenrequiresthat disagrees with the source, with422. A compiling page is stored with therequiresits source yields. - The CLI checks html pages against the shipped manifest when the project
has none (
9b402db, #20589), and against the project's ownsdui.manifest.jsonbeside the config it is given, not the one in the current directory (6981abf, #20675). The fallback lookup always failed before, so such projects got parse-level checks only.divand every other undeclared tag or prop is now refused (jsx-forbidden-tag,jsx-unknown-component,jsx-unknown-prop, exit 1) — including tags the console renders but the published manifest does not declare, such asavatarandcheckbox. - A public form no longer offers record search (
3dc33b2, #21222). The form field'spublicPickerblock is retired and refused, andGET /api/v1/forms/:slug/lookup/:fieldanswers404 ENDPOINT_NOT_FOUND. A public form'slookup,master_detailanduserfields are always left off its anonymous rendering. An earlier 17.6.0 change to that route's search key (bafb8c9, #21136) is listed in the CHANGELOG too; the route it changed is gone. page:header'sbreadcrumbis retired (f10d802, #20785). No renderer ever drew a trail for it; the app shell's own trail is unchanged. The CLI warns on it and a typedPageHeaderPropsfailstsc.action:buttonandaction:iconno longer declareendpoint(b3917d9, #21122). The console'sapihandler only ever readtarget, so a button written withendpointcalled nothing. The CLI warns with the rename.- Columns follow the inline-grid contract keyed by
namein a form view'ssubforms[].columns(bee75ce, #20927), arecord:line_itemsblock (24c554d, #21244) and anobject-master-detail-formblock'sdetails[](a29a0ea, #21215).field,fieldNameandkeyspellings are refused, and so isscaleon a column that renders as currency. object-gridandobject-kanbanblocks validategroupingas{ fields: [{ field, order?, collapsed? }] }(f750119, #20856); an off-shape value used to render one(empty)group.- A dimensionless dashboard widget with two or more
valuesis refused onpie,donut,funnel,scatter,radar,treemapandsankey(11d28c1, #21053), which drew onlyvalues[0]. The types that render several measures are exported asDASHBOARD_WIDGET_MULTI_MEASURE_TYPES.
Block properties are an open bag, so the page-block checks above surface as
component-props-* findings on os validate, os build and os lint and as
TypeScript errors; stored pages keep saving and loading. The exception is
scale on an object-master-detail-form detail column over a currency
field, which defineStack refuses (STACK_CROSS_REFERENCE_INVALID, 422).
Migration.
| you wrote | write instead |
|---|---|
a view body with objectName | object |
a ViewItem record's top-level options: { kanban: {…} } | config: { kanban: {…} }, or remove it |
<div>…</div> in a kind: 'html' page | <box>…</box> (same className and children) |
a hand-written requires on an html page | omit it; it is derived from the source |
publicPicker: {…} on a public form field | delete it; use a select with static options, or put the form behind sign-in |
breadcrumb: true / false on page:header | delete it |
endpoint: '/api/…' on action:button / action:icon | target: '/api/…' (with actionType: 'api') |
a subform / line-items / master-detail column { field: 'quantity' } | { name: 'quantity' }, and no scale on a currency column |
grouping: 'stage' or { fields: [] } | grouping: { fields: [{ field: 'stage' }] }, or delete it |
type: 'pie' with no dimension and values: ['a', 'b'] | type: 'table' or bar, or one widget per measure |
os migrate meta --from 17 lists the div, publicPicker, breadcrumb and
endpoint edits. The conversions form-field-public-picker-removed,
page-header-breadcrumb-removed, action-block-endpoint-to-target and
form-view-subform-columns-canonicalized rewrite stored rows and built
artifacts on load, and os migrate meta --stored --apply rewrites stored view
rows that still carry publicPicker. Custom action handlers that read
endpoint off an action must read target.
Author-time refusals that can fail a stack that built clean on 17.5.0
picklist-reference-unknown(b84b240, #21003): a field whosepicklistnames no picklist the stack declares failsos validateandos build. A typo such aspicklist: 'industy'used to pass.flow-api-trigger-secret-missing(e651556, #20593): anapiflow whose start node has no usableconfig.secret.translation-target-unknownnow judges every keyed child of an action's translation entry (ee42f00, #21258): anoutcomeMessages.*key for an outcome the action does not declare, or aresultDialog.fields.*key with no matchingpath, failsos validateandos build.dimension-json-stored-field-refusedand the widenedmeasure-aggregate-field-type-refused— see Cube and dataset members.- A key retired with
retiredKey()failstscwith a message that names the retirement (d830d71, #21023) instead of a barenot assignable to type 'undefined'. Code that reads a tombstoned key into a slot typedundefined(for exampleconst x: undefined = page.assignedProfiles) no longer compiles.
Warnings that fail only under --strict:
liveness-dead-propertynow fires (b616c0a, #21092). Four authorable keys aredeadtoday: adefineViewcontainer's ownnameandlabel(notlist.label), and a permission set'srowLevelSecurity[].labelandrowLevelSecurity[].description.unconsumed-widget-option(ce8a6d2, #21204) on a dashboard widgetoptionskey no renderer reads — anything outsidedateGranularity,description,limit,sortBy,sortOrderandstageOrder.os validate --strictnow fails on a source that still needs a live ADR-0087 conversion, because the CLI reads the conversionsdefineStackapplies (7a1faf1, #20579 — shipped in 17.5.0; see Shipped in 17.5.0).
Migration. Fix the name a picklist points at, or declare the list it
names. Rename an action translation key to a declared outcome or result-field
path, or delete it. Delete reads of tombstoned keys from your TypeScript (the
key never holds a value), and run os validate for each key's own migration.
Delete dead keys; move a widget's format and currency to the dataset
measure and a tile accent to colorVariant.
The CLI and the SDK
- The ADR-0087 migration chain moved to
@objectstack/spec/migrations(fbec216, #20695):MIGRATIONS_BY_MAJOR,MIGRATION_MAJORS,MIGRATION_SUPPORT_FLOOR,RETIRED_KEYS_BY_MAJOR,RETIRED_DEFS_BY_MAJOR,applyMetaMigrations,composeMigrationChain,MigrationFloorError,composeSpecChanges,composeReleaseChangesand theSpec*Schemachange manifests leave the package root.tscreports each site asTS2305. os environments create --clone-fromis gone (12fbb2f, #21100). The control plane never read it, so it created an empty environment and exited0. It is now an unknown flag (exit 2), andclient.environments.createno longer typesclone_from_environment_id.client.environments.delete's archive answer typesmessageas optional, beside a newoutcome(d6d6e87, #21214).os dev --no-watchturns watch mode off (c90f9fb, #20839). In monorepo mode, a PACKAGE argument that selects no workspace package now exits1, where it used to exit0with nothing started (os dev --watch=false, for example, ranpnpm --filter false dev); this needs pnpm 8.13.1 or later.os dev --no-watchin monorepo mode exits1and names the flag.@objectstack/clidepends on@oclif/core5 (e2ed61a, #21212). Only code that extends its exported Command classes is affected; command output and exit codes are unchanged.
Migration. Import the moved names from @objectstack/spec/migrations.
Remove --clone-from and clone_from_environment_id. Handle an undefined
message from environments.delete. Replace os dev --watch=false with
os dev --no-watch, and use pnpm 8.13.1 or later for monorepo mode.
Smaller breaking changes
- A package row with no
visibilityparses asorg, notprivate(32d3b3c, #21099).PackageSchema(@objectstack/spec/marketplace) filled an omittedvisibilitywithprivate, although no create path produced that value: the cloud control plane gives a new packageorg. In the same changeos plugin publishsendsvisibilityonly when--visibilityis passed, asos package publishnow does too (def279a, #20915). A re-publish keeps the package's current visibility, where it used to reset amarketplaceplugin toprivate(andos package publishmoved amarketplacepackage toorg). A firstos plugin publishwithout the flag now gets the control plane's default,orgon ObjectStack Cloud — installable in the owner organization's other environments — where it used to getprivate. - The datasource admin door judges the record it will persist against
DatasourceSchema(c6954d6, #21133).POST/PATCH /api/v1/datasourcesanswer400 DATASOURCE_ADMIN_ERRORandPOST /api/v1/datasources/testanswersok: falsewithout probing, for whatos buildandPUT /api/v1/meta/datasource/:namealready refused: a mongo url or composed config naming no user beside asecret(it connected anonymously),schemaMode: 'external'or'validate-only'with noexternalblock, a create with noconfig, an undeclaredpoolkey. APATCHthat changes onlylabeland/oractiveis not judged. - The knowledge service follows only
data.record.*events (e952cff, #21140). Its realtime bridge kept a branch for barerecord.created/record.updated/record.deletedevents, which no platform producer emits and whichRealtimeEventTypealready refuses (e73ee2d, #20567 — see Shipped in 17.5.0). MemoryAnalyticsServiceruns the shared filter doors (793fb83, #20857;95fed33, #20944). The in-memory analytics face of@objectstack/driver-memorynow refuses withINVALID_FILTER/400the comparands every other analytics face refuses —undefined, anullmember of$in/$nin,nullunder an ordering operator, a scalar for$in, an object comparand ({ d: { $ne: { a: 1 } } }answered every row) — and lowers aFilterArraywheresuch as[['stage', '=', 'won']]that it used to ignore, aggregating every row. It also compiles$or,$nulland$between. This mostly reaches tests, demos and dev setups.@objectstack/sdui-parserdrops theinert-quick-adddiagnostic (b8191f7, #20990) and the exportscheckKanbanQuickAdd,INERT_QUICK_ADD,QUICK_ADD_HOST_TYPESandQUICK_ADD_KEY. An authoredquickAddon<object-kanban>draws the genericunknown-propwarning instead;@objectstack/specalready refuses the key.
Migration. Pass visibility: 'private' where code relied on the old parse
default, and --visibility private on a first os plugin publish that must
stay private (or --visibility marketplace|org|private to change it on a
re-publish). For the datasource admin door, put the mongo user in the url
(mongodb://user@host/db) or in config.username, or send no secret; send
external: {} (or the federation settings) with schemaMode: 'external' /
'validate-only'; include config on create; remove undeclared pool keys.
A plugin that publishes bare record.* events for the knowledge index publishes
data.record.created|updated|deleted with the DataEvent payload (record in
after, id in recordId). Write in-memory analytics filters as you would for
any other face: { $null: true } for no value, a list for $in / $nin, and
the prefix form ['or', condA, condB] for an infix join. Match unknown-prop
on the quickAdd key, and delete imports of the removed parser names.
New capabilities in 17.6.0
Shared picklists. A new picklist metadata kind defines an option list
once — definePicklist in a *.picklist.ts file, or defineStack({ picklists }) — and a select, radio, multiselect, checkboxes or tags field
references it with picklist: 'NAME' (addbbf0, #20823). The runtime serves
picklist-bound fields with the resolved options on every object read,
relabelled per locale from picklists.NAME.options.VALUE, fills an omitted
field from a default: true option, and judges writes against the resolved
list with invalid_option (88b484e, #21047). Another package can only add
options, through defineStack({ picklistExtensions: [{ extend, options }] });
a duplicate value is refused 422 INVALID_METADATA, and uninstalling the
package removes its values. A field or extension naming a list the stack does
not declare fails os validate and os build (b84b240, #21003; 2821e9f,
#21049), and a packaged one fails boot. os i18n extract walks picklists and
os lint reports untranslated options as i18n/missing-picklist.
Filter by a related record's fields. The nested-relation filter { account: { industry: 'tech' } } is served in where on find, findOne,
count, aggregate, update and delete (ca5408c, #20872). The engine
reads the related object as the caller — its access check, row scope and field
permissions apply — and matches the relation against the returned ids ($in,
or an $or of $contains on multiple: true). One level only, at most 1,000
related ids per condition (RELATION_FILTER_ID_CAP), and not inside a
per-aggregation filter or having. The analytics query door runs the same
form through the engine (8d329f0, #20916). @objectstack/spec/data also
exports the shared filter lowering, lowerFilterCondition, which the engine and
the RLS seam now run once on every filter before a driver sees it (cfa9315,
#20794).
Import templates and what an import dropped.
GET /api/v1/data/:object/export?template=true returns an .xlsx import
template: one column per importable field the caller may write, an example row,
dropdowns for select, radio and boolean columns, * on a column whose blank
the import would refuse, and a second sheet describing each column — in
Chinese for a zh locale (e5c7d07, #20683; 6f1f1c1, #20904). It is gated
by the import door's permission (create on the object), not by allowExport
(8f78495, #20977). Each successful import row, on the dry run and on the
commit, now reports the fields its write dropped — a formula, static
readonly or runtime-owned column — as droppedFields (bbcd20c, #21203;
95555e7, #20930), and ObjectQL.validate / insertMany report the same per
row (657b6b7, #21041).
Pull records through a connector. A mapping can name a connectorSource
(0efbdc3, #20903), and AutomationServicePlugin.pullConnectorSource({ mapping, context }) makes one call to a declared rest or openapi
connector's read action, projects the records through the mapping's
fieldMapping and writes them through the same import runner the REST import
door uses, with a watermark for an incremental pull (8368f1c, #21084).
Nothing schedules a pull yet. The runner (runImport and its cell coercion)
and the REST data-error table (mapDataError) now live in @objectstack/core
and @objectstack/types; @objectstack/rest re-exports every name it exported
before.
Turso remote numbering. The Turso remote transport — the one every hosted
tenant database uses — issues auto_number values on create(),
bulkCreate() and upsert() from the same _objectstack_sequences counter and
format rules as the local transports (e35c40a, #21160). On 17.5.0 no object
with an auto_number field could get a new record there; the driver answered
501 NOT_IMPLEMENTED. A sequences table that predates key_hash is refused in
remote mode with a 500 naming the remedy: open the database once through the
local or embedded-replica transport to migrate it.
Field-level security, answered. The security service answers
getWritableFields(object, context) (e5c7d07, #20683) and
getQueryableFields(object, context) (83480c6, #20955) beside
getReadableFields: the fields a caller's write may name, and the fields a
query may filter, sort, group or aggregate by. Both are optional on
ISecurityService and fail soft.
Analytics. GET /api/v1/analytics/meta publishes a cube's, a measure's
and a dimension's description and each measure's format when the definition
declares them (03cdb9a, #20736). Every dataset answer names its base object as
object (10c36cc, #20712; 35587f7, #20687), and AnalyticsResult types
the four drill-through sidecars a drillable answer already carried
(671d4c1, #20720).
Flows. A run's result carries the flow's authored label as flowLabel
on AutomationResult and in the trigger and resume responses (5363e2d,
#20633). POST /api/v1/automation/:name/clone is mounted, so cloning a flow
under a new name works over HTTP and from Setup's Clone dialog (96e7244,
#20779). A credential typed as a literal into an http node's headers or url,
or into a connector_action node's input, draws a flow-credential-literal
warning — flow definitions are served as authored to every member who can read
flows (ed54768, #20698). A host that turns scheduled work off for one kernel
can say why with ScheduledWorkPolicy.hostDisabledReason, which the bind log,
getTriggerBindingAudit() and GET /automation/_status then report instead of
the deployment sentence (748b240, #21270).
Objects, pages and views.
ObjectSchema.imageFieldnames theimageoravatarfield that is a record's picture (9969228, #21221). It is accepted, stored and served; no renderer reads it yet.object-gridblocks declaredescription,emptyStateandkeyboardNavigation, and validate clean with them (f5c7b2c, #20882).- The field metadata form offers
useGroupingonnumberfields (31c3996, #20934). - The SDUI parser accepts the base props
bind,hidden,visibleWhen,hiddenOnandtestIdon every node, soos validatestops warning on them (5bed1f6, #20799). - Every page walk descends a
page:card'sfooter, soos i18n extractoffers andtranslatePagetranslates the components there (315888d, #20961). - A stored page whose
requiresnames a plugin the console does not load is reported at boot as[page_requires_plugin_absent](250dec8, #21121). - An organization's published edit to a packaged dashboard or view is served
by the
/metareads as written in every locale, instead of the packaged translation of the string it replaced (7afdc5c, #20832;1940afd, #20728;9ad6544, #20770). The console may still draw the packaged translation.
Identity. Discovery reports authFamilies.admin, saying whether the
better-auth admin routes are mounted (70dae53, #21145). AuthPlugin accepts
hostSignInHandoff: true for a host that signs people in through its own
handoff route, which turns the no_sign_in_account_at_boot boot report into a
debug line (33b6e8b, #20893).
Migration tooling. os migrate audit-metadata-bodies rewrites
metadata-body copies at rest (336e191, #21144; see Stored metadata
bodies).
os migrate meta --from N prints the schema verdict first, then the applied
edits, then the manual changes (3b47a69, #20691), and follows each applied
edit that needs judgment with a line naming the manual change that judges it
(6073bb9, #21025; 6afccda, #20716). A refusal thrown by defineStack or
composeStacks carries the conversions applied before it, readable with
stackConversionsOf(error), and --json reports them beside the refusal
(d7631d5, #20651; 87847a2, #20926). The stored-filter conversion
page-component-filter-record-to-rule-array now also rewrites filters on
object-map, object-tree, object-calendar and object-gantt blocks with
inline rows (c4c68ca, #20660).
Notable fixes in 17.6.0
These are the fixes an upgrading deployment is most likely to notice. Everything else is in the per-package CHANGELOG.md files, which is
what they are for.
Security.
- A row-level write
checkjudges the value as it will be stored: a lone scalar written to a multi-valued field as the one-member list it is stored as (d2bc644, #21253), anddate,datetimeandtimecolumns in their stored form (ef96c9e, #21235). Before,!record.tags.contains('x')admitted a write oftags: 'x'that the read then hid, so a policy forbiddingxcould be passed by sending'x'instead of['x']. - The analytics query door refuses with
403a caller-named member that is neither a declared cube member nor a column reference, in every tier, and a member whosesqlis not a column reference where the field-level read gate judges the object (0b12b9e, #21173;ae1e950, #21153). Such text could reach a native statement unjudged. - Activity rows serve a parent record's field values — in the recorded change,
the summary and the record label — only to a reader the security service
serves that field (
2488b98, #21152), and the compliance ledger's before/after snapshots are narrowed at read time the same way (fbcc05f, #21171). - Served flow definitions withhold every credential they hold — an
httpnode'sconfig.signingSecretas well as anapistart node'sconfig.secret— at any depth, including insideloop,parallelandtry_catchbodies (3f45b6c, #20615). A save that omits the key keeps the stored secret; savesigningSecret: ''to remove it. @objectstack/metadatarequiresjs-yaml^5.4.1, clearing GHSA-r3ph-w7gj-g6xm (61455de, #20719).
Flows.
- ⚠️ An
httpnode'ssigningSecretnow signs the request on every arm withX-Objectstack-Signature: sha256=…(89801cd, #20640). Only the durable outbox arm signed before; the default inline request went out unsigned while the run reported success. A non-emptysigningSecretthat renders to nothing at run time now fails the node instead of sending unsigned. - A packaged flow switched off in the activation ledger stays unbound after a
restart, and re-enabling the ledger bit of a flow whose
statusis stillobsolete/invalidno longer arms it (defc7f7, #20702). A trigger-fired run refused because its flow is disabled logs atinfo, notERROR. - For a flow name a managed package ships, the flow list and the by-name,
/layersand/publishedreads all serve the package's flow, and every startup step arms it (25f2e64, #20994;94990a2, #21043;514001a, #21116;75519e1, #20942).
Drivers and databases.
- MySQL: a write to an object that does not declare
created_at/updated_at—sys_jwks,sys_memberand everymanagedBy: 'better-auth'orsystemFields: falseobject — no longer fails withIncorrect datetime value(95b91cc, #21272). On 17.5.0 the JWT signing key was never stored, so/auth/jwksand/auth/tokenanswered500and no OIDC or MCP token could be issued, and the seeded admin had no organization membership. Adatetimefield withdefaultValue: 'NOW()'creates its table (7923c8e, #21252), andcreate/bulkCreatereturn the stored rows instead of the insert id (be5a83c, #21239). - SQLite: an autonumber whose rendered prefix contains
_,%or\seeds its counter from the highest stored number; the seed query matched no stored rows (c6b6889, #21206). - Turso remote: an
upsertkeyed on a business column keeps the stored row'sidand returns the stored row (ebdb6f2, #21184). - In-memory driver:
$contains/$notContainson a multi-valued or JSON-stored field test whole-element membership, as the SQL drivers do (f8178ff, #20984), andsum/avguse compensated summation (b785c3b, #20739). - Dates before year 100 are read as written, not as 1900–1999, wherever the
platform builds a UTC instant from parts (
a6866da, #20746). Exports write four-digit years, so adatein 0001–0999 re-imports; adatetimebefore 1000 is still refused (67c1b11, #20688). - The first boot of a new SQL database, and
os migrate planon a database that does not exist yet, no longer print spuriousDATABASE_ERRORlines (810d42b, #20818;cf0346e, #21093).
Analytics. On the native-SQL strategy, base-table columns are qualified
whenever the statement joins a related object, fixing ambiguous-column 500s
on cubes that declare no join (2791138, #21266). On PostgreSQL, measures come
back as JSON numbers, not strings (d1633f3, #21040), sum / avg accumulate
in double, and a boolean aggregand no longer answers 500; on every dialect an
all-NULL sum answers 0 (097ef80, #21209). $not, $notContains and null tests over a
multi-valued lookup return rows on the engine-aggregate path instead of 400
(5dbeb7d, #21036). Date-bucket keys spell four-digit years at every
granularity (856321f, #20865; 525b813, #20971).
Import. A failed import row names its column in field, reports a missing
database column with the create door's schema-drift message instead of the raw
database error, and reports a NOT NULL or unique-conflict refusal with the same
code, field and sentence as the create door (165c1d4, #20905; f80e2a6,
#20941; d7b9817, #20956).
CLI. os migrate plan and os migrate apply no longer run the app's
onEnable or host plugins' kernel:bootstrapped / kernel:listening hooks
during their boot (f20f669, #21138). os start forwards SIGTERM and
SIGINT to its serve child and stops it when it exits (7164587, #21161);
before, docker stop or a systemd stop ended start and left serve running
with the port bound. os validate and os lint no longer exit 1 on a mapping
that authors connectorSource (9bdc6d3, #21176), and os migrate meta
converts objects built with ObjectSchema.create(…) instead of stopping at load
(d2b188f, #20801).
Everything else.
- Presigned S3 upload URLs no longer carry the checksum of an empty body, so
stores that enforce query-signed checksums accept browser uploads
(
f0cc16e, #21164). - The OIDC and OAuth discovery documents answer on every boot, including when
a readiness probe arrives before the auth instance is built; before, all five
answered
404until the next restart (432c8ab, #21132). GET /api/v1/meta/objectserves an object's embeddedlistViewsin the reader's language (c27404f, #21072), and the ja-JP, es-ES and zh-CN platform labels that contradicted their English source are re-translated (f4ce10c, #20652;5757463, #20707;7184436, #20684).security/explainresolves the explained user in the organization enforcement resolves them in, refuses a cross-class field-to-field policy at the object level too, as enforcement does, and answers a classified refusal with its own 4xx instead of500 EXPLAIN_FAILED(889139c, #20614;cd901d7, #20629;72f8c38, #20858).- Setup and Studio carry navigation entries for the Audit Log Browser and
Integrations & APIs pages (
fa0a4b6, #20699). - Refusals, warnings, log lines and CLI help across the CLI, REST, the engine
and the drivers state the decision behind them in words instead of citing
tracker numbers (
b9087d7, #21172;49d2a24, #21231;f115b1f, #21188;3fbf3ca, #20924;42d78b9, #20877). Tests that match those messages verbatim need updating.
New in Console (Studio) — objectui pins in 17.6.0
Three pin moves carry the console half of this release:
dd3f7e1be356 → db11afd4967c (a3d7588, #20706),
db11afd4967c → e420df310f5b (b8191f7, #20990) and
e420df310f5b → 31971ff1e28f (0d42104, #21149). Together they carry 232
releasing objectui changesets (89, 87 and 56) across 247 commits; the
per-commit lists are in packages/console/CHANGELOG.md under ## 17.6.0.
⚠️ Console hosts and authors: 23 of those entries are declared breaking
upstream (6, 5 and 12). They are objectui's own surfaces — they matter to a host
that builds on @object-ui/* packages or authors objectui page JSON directly.
Four of them mirror ObjectStack keys already retired in 17.5.0
(assignedProfiles on a page, chartConfig.aria, aria on an action,
quickAdd on object-kanban), and none registers a new ADR-0087 migration.
- Studio works again with the 17.6.0 spec. The previous pin bundled a
second zod instance (objectui's 4.6.5) beside the injected spec's 4.6.1,
which broke Studio's
spec-derived forms: the New Package dialog could not create a package and the
dashboard and report inspectors showed no schema. The console now bundles one
zod instance (objectui#11353, in
0d42104). - Grouped grids whose columns are objects load their rows again instead of
showing
INVALID_FIELDin every group (objectui#11105) — the console defect HotCRM's 17.5.0 upgrade recorded as a known issue. - Studio saves the item you are editing, and only that item. Switching Studio to another flow, page or package no longer saves the previous item's unsaved edit into the one just opened; draft autosave keeps an edit made while a save is in flight; nav autosave sends every nav edit it has shown; and a pillar whose draft load is cancelled no longer stays on "Loading…" (objectui#11331). Read-only packages are honoured on the Automations pillar's Enabled switch, inspector and canvas, and on the Interfaces pillar.
- Labels written as locale maps render in the viewer's language — on
navigation entries (objectui#11299),
object-formand master-detail form titles and buttons,record:pathstages,object-grid, the metric tile and Studio previews — where they used to crash or print[object Object]. - Breaking for authored page JSON (objectui changesets):
object-gantt,object-chart,object-mapandobject-formtake their props only in thepropertiesbag, and the flat spelling is refused by name; bare-stringglobalFilters[].optionsare no longer lifted (write{ value, label });drillDownis retired on the barepivotnode (authorobject-pivot);quickAddandonQuickAddare retired onobject-kanban, andshowFiltersonobject-grid; nineteen public blocks andmetric-cardrefuse an authoredchildren, and thirteen node types plusinputrefuse bothbodyandchildren; formula and summary widgets read onlyreturnTypeandsummaryOperations; a saved view is read by the spec's spellings and matched byobject/name, and stops honouringallowExport,wrapHeadersandeditRecordsInline;object-treeno longer treats a bare-arraydataas rows (usestaticData); and the Field Designer no longer offersselectfor a new field, while the object write guard holds aselect/radiofield with no option source. - New:
docnavigation entries, a Studio Markdown editor fordocitems and a docs portal that refuses docs the member may not read (objectui#10188);current_user.can(object, verb)in an action'svisible/disabled;{record_id}as a filter value on record pages (objectui#7297);record:related_listrenders itsactions, and a related list places the child object'srecord_relatedactions on each row; Setup › Packaged automation shows the platform's reason for an unarmed packaged flow (objectui#9217); a number field'suseGroupingdecides thousands separators;object-gridhonoursdescriptionandemptyState; dashboards honourrefreshIntervalSeconds; and Studio's flow start node writes theapitrigger the engine routes and can set its secret, and stops offering 「Platform event」.
⚠️ Known console issues at this pin. Three 17.6.0 server changes refuse a
body the pinned Studio still sends; objectui has fixed each one after
31971ff1e28f:
- Studio's dataset designer seeds new measure and dimension rows with
field: '', which a dataset save now refuses (Cube and dataset members). Fixed in objectui0858267e(objectui#11402). - The datasource editor creates an External or Validate-only datasource without
the
externalblock the admin door now requires, so such a save without a credential is refused (Smaller breaking changes). Fixed in objectui8001068b(objectui#11368). - Both page editors send the served
requiresback, so an html page that gains a plugin component saves as a draft but its publish is refused422(page-requires-disagrees-with-source). Fixed in objectui3ae91930(objectui#11357).
Until the pin carries those fixes, author the count measure, the federated datasource and the html page through the metadata API or in source, where the shapes above are accepted.
Shipped in 17.5.0 — listed again in 17.6.0's CHANGELOG
Sixteen of the changesets in 17.6.0's CHANGELOG.md files, from fifteen
commits, describe code that was already published in 17.5.0. A deployment
on 17.5.0 already runs them, and nothing here changes when it moves to 17.6.0.
They are listed again because the 17.5.0 version commit 8c87d26a did not
consume their changesets:
-
Eight commits follow the version commit in first-parent order, before the publish ran from
0f6dcac5:6e3aa75(#20584),a093ce3(#20582),92fe081(#20458),3a89d45(#20591),7001918(#20598),c96beb2(#20585),ba4648d(#20605) and0f6dcac(#20606). The 17.5.0 notes already describe them under Also shipped in 17.5.0, including the migration for the retired cube membername. The pipeline defect that let a publish run past its version commit is fixed in this release (#20625, #20613). -
Seven commits are ancestors of the version commit — they landed on
mainbetween 04:09 and 05:36 UTC on 2026-09-29 — but were not yet in the version PR when it merged, so their changesets stayed unconsumed. The 17.5.0 notes do not mention them. Two are breaking, and one makesos validate --strictfail:RealtimeEventTypelists only the events the engine publishes (e73ee2d, #20567):data.record.created,data.record.updated,data.record.deleted,data.records.updatedanddata.records.deleted.record.created,record.updated,record.deletedandfield.changed— never emitted, so a subscription naming them never fired — are refused bySubscriptionSchema,RealtimeConfigSchemaandtsc. Renamerecord.*todata.record.*(adddata.records.updated/data.records.deletedfor predicate writes), andfield.changedtodata.record.updated, reading the field from the payload'schanges.- A Turso config that forces
mode: 'replica'without a non-emptysyncUrlis refused at authoring and when the driver is built (c876a74, #20504). It used to run as a plain local database that never synced. A stored datasource row in this shape is not re-parsed on load, so its driver build fails. For an embedded replica keep thefile:url and name the remote insyncUrl; for a plain local database dropmode. os validate --strictexits 1 while a source still needs an ADR-0087 conversion (7a1faf1, #20579), andos validate/os buildlist the conversionsdefineStackapplied in--jsonconversions, which used to answer[]. Author the canonical spelling each conversion notice prints.
The other four change no behaviour:
c9d234c(#20577) rewords the non-numeric comparand refusal athaving;24d521e(#20572) andf11b5f2(#20568) restructure and reword the protocol-18 migration guidance;2123fcc(#20576) changes source comments only.
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.6.0
⛔ Nobody has walked 17.5.0 → 17.6.0. Every line below is derived from a Migration note in Breaking changes & migration in 17.6.0 or from a changeset of this release, and is marked not exercised: accurate about what changed, unproven about what it costs to cross. A step nobody has run, presented beside steps that were, is how a reader finishes a checklist and believes they are done — so this list claims nothing it has not been given.
Before you upgrade
- Find every app-declared anonymous endpoint (
authRequired: false) that reads or writes objects. After the upgrade it is refused every object, and no supported channel can grant anonymous callers a permission set until #21158 lands; hold the upgrade if you depend on one. Not exercised. - Grant a permission set to signed-in users on an embedder that sets
fallbackPermissionSet: null; without one they are refused every object. Not exercised. - Grant
manage_platform_settingsto every user or integration that readsdatasourceorexternal_catalogmetadata, or writesdatasource, through/api/v1/metawhile holding onlymanage_metadata,studio.accessorsetup.access. Not exercised. - List stored flows that share a packaged flow's name — startup warnings or
getShadowedFlows()— and clone each one you still need under a new name; after the upgrade they no longer run. Not exercised. - Find stored
datetimevalues before year 1000 with$lt '1000-01-01T00:00:00.000Z'and rewrite them, or set them tonull; any write that carries one is refused after the upgrade. Not exercised. - Fix Turso datasource configs, authored and stored, that force
mode: 'local'beside a non-emptysyncUrl; a bound or boot-critical one fails the boot. Not exercised.
Getting onto the release
- Move all the
@objectstack/*pins as one set and regenerate the lockfile — Moving the dependency pins. If a scanner then flags an olderhonounder@modelcontextprotocol/sdk, runpnpm update hono; objectstack never loads that copy (bae3859, #20667). Not exercised. - Leave the protocol declarations on 17:
engines.protocol: '^17'and a^17.0.0specVersion. 17.6.0 still implements protocol 17. Not exercised. - Run
os migrate meta --from 17, thenos migrate meta --stored --applyfor the new conversions —publicPicker,breadcrumb, actionendpoint, subformcolumns, cuberefreshKey, the empty count-measurefield, connectortriggers/syncConfig/fieldMappings,timedefaults ending inZ— and for page filters on inline-row blocks, which--storednow lists as pending until applied. Not exercised. - Run
os migrate audit-metadata-bodies, thenos migrate audit-metadata-bodies --apply, to rewrite metadata-body copies already at rest insys_audit_logandsys_activity. Not exercised.
Metadata and build — run os validate before you ship
- Rewrite every cube member
sqland dataset memberfieldthat is not a column reference —CASE WHEN, aggregates, ratios — as a dataset measure with its ownfilter, or aderivedmeasure;tscdoes not catch these. Delete cuberefreshKey. Not exercised. - Delete connector
triggers,syncConfigandfieldMappings, and move a sync you still want to amappingwithconnectorSource. Not exercised. - Rewrite the page and view shapes:
div→boxinkind: 'html'pages, no hand-writtenrequires,endpoint→targetonaction:button/action:icon, subform / line-items / master-detail columns keyed byname,groupingas{ fields: [{ field }] }, and deletepublicPickerandpage:headerbreadcrumb. Not exercised. - Fix the new author-time refusals: a
picklistorpicklistExtensionsentry naming no declared list, anapiflow with noconfig.secret, action translation keys for undeclared outcomes or result fields, dimensions over JSON-stored or multi-value fields, and dimensionlesspie/donut/funnel/scatter/radar/treemap/sankeywidgets with two or morevalues. Not exercised. - Write
timedefaults and values as a bare wall clock ("10:00"); rewrite by hand the storedtimedefaults with a non-zero offset thatos migrate meta --storedlists. Not exercised.
Data and database
- Review sharing rules and views that use 「is empty」 on text or
multi-value fields; they now also match
''and[]. Not exercised. - Review saved filters, list views, dashboard widgets and reports for the newly refused filter and aggregate shapes; there is no mechanical rewrite. Not exercised.
- Send import files with ISO 8601 dates (or the export's own
YYYY-MM-DD HH:mm:ss); month-first, day-first and Excel-serial cells now fail their row. Not exercised. - Remote Turso: if the driver reports a
_objectstack_sequencestable withoutkey_hash, open the database once through the local or embedded-replica transport. Not exercised.
Deployment and configuration
- Grant auditors and reviewers what they need to read: a permission set that unmasks the snapshot fields they must see, and system context for a server-side job that must read every ledger row. Not exercised.
- Pass
--visibility privateon a firstos plugin publishthat must stay private. Not exercised. - Know the console issues at this pin: count measures with a blank field, External / Validate-only datasources without a credential, and republishing an html page that gained a plugin component are refused from Studio; use the metadata API or source for them. Not exercised.
- If you override
validation.field.invalid_dateorinvalid_datetime, also defineinvalid_date_range/invalid_datetime_range(f6ccca4, #20952). Not exercised.
Application code, hooks and flows
- Switch customer-authored flows off with
status: 'obsolete', not the toggle; clone (then remove) any customer flow the toggle already switched off. Enable packaged subflows before their callers. Rename flows whose name starts with_. Not exercised. - Make sure every
httpnode'ssigningSecretrenders to a value, or writesigningSecret: ''to send unsigned on purpose. Not exercised. - Stop sending
formulavalues when you passstrictReadonlyWrites(otherwise they are stripped), read a submitted formula value fromctx.submitted, and handlereason: 'computed'inDroppedFieldsEventbranches. Not exercised. - Import the migration-chain names from
@objectstack/spec/migrations; remove--clone-fromandclone_from_environment_id; useos dev --no-watch; testnextUtcCalendarDayanswers withisUnboundedAbove. Not exercised. - Custom hosts and drivers: pass
getReadableFields,getQueryableFieldsand (withexecuteRawSql)hasObjectMiddlewareto a hand-builtAnalyticsService; implementfindOneon stand-ins forMigrationFlagEngine/SeedTenancyLedger; lower filters withlowerFilterConditionbefore a direct driver call; answerfalseand the deny filter from anISecurityServicefor a caller with no permission set. Not exercised. - Publish
data.record.*events, not barerecord.*, from a plugin that feeds the knowledge index. Not exercised.