17.5.0
Release notes and upgrade checklist for 17.5.0 of the v17 line.
Highlights — 17.5.0
- Package-authored scheduled work is off until a deployment turns it on
(
f04be62, #18198, #17334, #18420). A new deployment variable,OS_AUTOMATION_SCHEDULED_WORK_ENABLED, gates every time-triggered flow and every packageddefineJobcron job, and it is off by default in every tenancy posture. Platform-internal scheduled work (approvals escalation, the lifecycle Reaper, messaging dispatch, membership backfill) is not gated. With the switch on,isolatedrequires each scheduled flow to declare the organization it acts as. ⚠️ A deployment that upgrades and does nothing runs no packaged scheduled flow and no packaged job. - An edge-branched
decisiontakes only its first matching branch (0283cb9, #20344). A decision with noconfig.conditionsused to run every out-edge whose condition held; it now runs the first one in declaration order, andmode: 'inclusive'keeps every true branch.os migrate meta --from 17writesmode: 'inclusive'into authored sources, but ⚠️ a flow stored insys_metadatais not rewritten and switches to first-match on upgrade. - Row-level security stops admitting what it cannot enforce. A policy with
no
checknow holds inserts and updates to itsusing(b7c792b, #19952) — the documented default that the write gate never applied. Predicates that compare against a list, a nested list, an object, the barecurrent_userroot or an undeclared column no longer lower to a filter every row satisfies; they are refused and the policy fails closed (#19947, #19946, #20259, #20310, #20189,7026141). The row-levelcheckand the tenant write wall are judged on the row the engine actually stores, afterbeforeInsert/beforeUpdate(a016f08, #19988, #20012, #20043). - Analytics answers only what the caller may read.
POST /analytics/dataset/query,/analytics/queryand/analytics/sqlnow check the object-level read grant — base object and every joined object — and answer403 PERMISSION_DENIEDwhereverGET /data/<object>does (041d9fd). Before, a caller with no grant on an object could read its row counts, and grouped counts by any column, on a SQL driver. - The saved-report stack is removed (
8d1f7ab, #20125):/api/v1/reports,client.reports,IReportService, thereportscapability,sys_saved_report/sys_report_scheduleand@objectstack/plugin-reports, with no successor for scheduled delivery. Thereportmetadata kind, datasets and the analytics service are unchanged. - Walled deployments take platform-admin standing only from
OS_PLATFORM_OWNER_EMAIL(74832b6, #19136). An unscopedadmin_full_accessgrant row no longer confers it undergroup/isolated. ⚠️ Declare each administrator's verified address before upgrading, or a walled deployment comes up with zero platform admins. sys_account.issueris dropped and better-auth moves to exactly1.7.3(9bd4344, #17454). Account identity is(provider_id, account_id); run the read-onlyos migrate account-issuerpre-flight beforeos migrate apply --allow-destructive.- An anonymous
GET /auth/get-sessionanswers401 UNAUTHENTICATEDinstead of200 null, soclient.auth.me()rejects when nobody is signed in (374d9d3, #17881). - The data engine declares — and enforces — what it returns.
findOne,updateanddeletestop beingPromise<any>, and a hook or driver answer outside the declared shape is refused with a500(854639b, #17255). An action handler'sctx.engine.find(object, query)takes the query envelope, not a bare filter (7d0f911, #19223) — a handler that already passed an envelope was silently getting[]. - One comparand rulebook at every filter door. Arrays in the equality slot,
arrays under
$ne,nulllist members, blank$betweenendpoints, text operators on non-text columns and non-objectwherevalues are refused withINVALID_FILTER/400on the engine, inhavingand per-aggregationfilter, at the analyticswheredoor, and at save time on every stored filter carrier. - A field-level
requiredWhen/readonlyWhenthat cannot be evaluated refuses the write (5dba7f3, #20028, ADR-0137 D2) instead of saving with the field empty or letting a frozen field change. - Written values are held to their field's declared type. A
datestring is written as itsYYYY-MM-DDday and adatetimestring only in an ISO 8601 spelling, both on a day that exists and in the years 0001–9999; a number field reads a string only by the JSON number grammar; and a declaredprecision, or aprogressfield'smin/max, now binds. Each refusal is400 VALIDATION_FAILEDwhere the value used to be stored as sent or as a different value (b2b6a06, #20524;92ea760, #20547;3062e50, #20469;2b24b8b, #20496;b98fbc2, #20423;9801da1, #20482). os validateandos buildrefuse a config whose default exportdefineStack(...)orcomposeStacks(...)did not build (ba5927f, #20460):STACK_PROVENANCE_MISSING, exit 1. A plain object, or a spread copy such as{ ...defineStack({ … }), api }, skipped every stack-level refusal and shipped. Wrap the export indefineStack(...).- Validation rules can read one hop through a lookup —
record.account.type(1f05ea4, #19728) — and CEL gainscurrent_user.can(object, verb), which the server now answers in optionvisibleWhen, formula fields and CEL defaults (#18781, #20079, #20138). - A view with no declared page size shows 50 rows, not 25 (
8ecbe0f, #20184). - Console: four objectui pin moves —
53ded82bf7a4 → 87af769e9a3e → 62597c588072 → f8a9d0fb0596 → dd3f7e1be356(fbc12be,48c91e9,0bf85ea,3cf6449) — the first of them carrying 584 releasing objectui changesets, 98 of them declared breaking upstream, and the last 325, 41 of them declared breaking.
What's new in 17.5.0
17.5.0 was published to the latest tag on 2026-09-29, 20 days after
17.4.0, moving the whole version-locked train and no major. The version commit
8c87d26a consumed 958 changesets, and that is the count this page uses.
The 69 package CHANGELOG.md files that carry a 17.5.0 section list them as
1,372 per-package entries (703 minor, 669 patch) in 56 of those files, because
a changeset that bumps several packages is listed in each; the entries
de-duplicate to the same 958. The bundled Console advances four pins,
53ded82bf7a4 → 87af769e9a3e → 62597c588072 → f8a9d0fb0596 → dd3f7e1be356.
The npm packages also carry eight commits that landed after the version
commit and are in no 17.5.0 CHANGELOG.md entry — see Also shipped in
17.5.0.
⚠️ 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 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:
- packaged scheduled flows and jobs stop running until
OS_AUTOMATION_SCHEDULED_WORK_ENABLEDis set; - row-level security narrows —
using-only policies now gate writes, and unenforceable predicates now deny instead of admitting; - analytics refuses callers who lack the object read grant;
- walled deployments stop honouring
admin_full_accessfor platform standing; - an anonymous session read answers
401, andclient.auth.me()rejects; sys_notification_deliverykeeps failed deliveries for 7 days, not 90;- the default page size doubles to 50;
- an edge-branched
decisionstored insys_metadatatakes only its first matching branch; - record writes refuse
date,datetimeand number strings outside their declared spellings, and values past a declaredprecisionor aprogressfield's bounds; - an
apiflow whose start node carries noconfig.secretstops registering; - a cube declared
public: false— as every cube in an artifact compiled before this release is — is hidden from analytics and refused404; - a principal acting with no active organization holds only its global grants.
Breaking changes & migration in 17.5.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 four Console pin refreshes are named once under New
in Console rather than
enumerated here.
Most retirements in this release are registered as ADR-0087 conversions under
protocol major 18. os migrate meta --from 17 lists the source edits, and
os migrate meta --stored --apply rewrites stored rows where a lossless
conversion exists — since this release --to defaults to the highest major the
installed @objectstack/spec has a step for, so the command the refusals
prescribe no longer answers ✓ Nothing to migrate (fb39b38, #17462).
"Protocol 18" is the migration registry's next major, not the runtime's:
17.5.0 still implements protocol 17 (os migrate meta prints "this runtime
implements protocol 17"), and its schemas already refuse every shape those
steps convert. An app therefore keeps engines.protocol: '^17' — see the
upgrade checklist.
Scheduled work is off until a deployment turns it on (#18198, #17334, #18420)
OS_AUTOMATION_SCHEDULED_WORK_ENABLED decides whether a deployment runs
package-authored scheduled work at all: time-triggered flows
(type: 'schedule' with a config.schedule cadence, and the timeRelative sweep) and
packaged defineJob cron jobs. Only true / 1 / on / yes
(case-insensitive) turn it on. Flows switched off are listed in
getTriggerBindingAudit() and in the os dev / os start summary as "disabled
by deployment policy", and os doctor prints the effective value.
With the switch on, the tenancy posture decides who a scheduled run acts as:
| posture | what a scheduled flow must declare |
|---|---|
single | nothing — runs as before |
group | optional config.organization; an undeclared run acts as the swept record's own organization |
isolated | required config.organization on the start node, or the flow is not armed |
Scheduled runs now also write sys_automation_run history rows, capped by that
table's existing runHistoryMaxPerFlow (default 100).
Migration. Set OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true if you depend on
packaged scheduled flows or jobs. Under isolated — and under group when one
fixed organization is meant — add organization: '<sys_organization.id>' to the
start-node config of every schedule / time_relative flow; there is no
fan-out, so work wanted in N organizations needs N flows. The changesets name
four effects of splitting under a wall: organization_id IS NULL rows are
matched once per flow (backfill the column, or declare the object
tenancy: { enabled: false }); dispatch dedup keys embed the flow name, so cut over at a
window boundary; suspended runs from before the upgrade resume org-less, so
drain them first; and driver-memory refuses tenant-scoped calls.
Row-level security fails closed on what it cannot enforce
Every entry below changes who can read or write on an existing deployment. None of them has anything to parse-fail on at upgrade.
- A policy with no
checknow holds INSERTs and UPDATEs to itsusing(b7c792b, #19952).RowLevelSecurityPolicySchema.checkhas always been documented as defaulting tousing, but the write gate compiled only policies that declaredcheck, so a caller could insert a row it could not read back. A single-row insert or by-id update whose resulting row falls outside theusingof every applicable write-class policy (insert,update,all) is now403 PERMISSION_DENIED. This also tightens the platform_selfpolicies: a member can no longer create asys_user_preferencerow for another user, or re-own asys_api_keyby addinguser_idto a revoke patch. - Unenforceable comparands are refused, not lowered to "every row"
(
9347c1f, #19947, #19946, #20259, #20310).record.status != ['closed', 'archived'],!(record.status == [...]), a nested list underin, an ordering against a list, and a comparison with ajson/multiplefield all lowered to filters every post-image satisfied — so acheckadmitted every write it was written to refuse, and ondriver-mongodba$ne-against-arrayusingreturned the rows it was written to hide.compileCelToFilternow refuses these shapes and the RLS compiler drops the policy into the deny sentinel: reads return no rows,checkwrites answer403. - A comparison with the bare
current_userroot fails closed (560b724, #20189).record.owner_id != current_usercompared against the whole caller object, which no value ever equals, so such acheckadmitted every write. - A predicate naming an undeclared column denies in every position
(
7026141). A phantom column in a negated or non-leading position widened reads to every row inside the tenant wall and permitted writes on every driver. driver-mongodbrefuses a{ $field }cross-field reference (98f722a, #20182) instead of sending it as a literal sub-document, which had dropped the read restriction of a policy such asusing: 's != t'.current_user.accessible_org_idsresolves (470746a). Policies that used it returned zero rows; they now return the caller's accessible organizations' rows — this one widens.- The row-level
checkand the Layer 0 tenant write wall are judged on the stored row (a016f08, #19988, #20012, #20043). The insertcheckused to see the raw payload before defaults andbeforeInsert; an array insert installed no check; a predicate update never judged its new rows; and a hook could move a row into another organization. All are now judged on the image the engine stores. The correction cuts both ways: an insert whose hook stamps the scoping field now succeeds, one whose hook stamps an out-of-scope value is now refused. checkon aselectordeletepolicy is refused at parse (b276d44, #20167) — it was stored and never evaluated.- A field-to-field comparison across comparison classes is refused on every
path (
2c31070, #20403;aeb0557, #20427).record.status != record.amount(text and a number), or a comparison with a file, formula orjsonfield, already had every read it scopes refused on the SQL drivers, while the write check compared the two raw values in-process and stored the row whenever they happened to compare true. The write check now refuses it with the read'sINVALID_FILTER/400and stores nothing, andos validate,os build,os lintand the permission-set save door refuse it when it is authored — assharing-rule-unlowerable-conditionon a sharing-rule condition. The classification is exported from@objectstack/spec/data(CROSS_FIELD_COMPARISON_CLASSES,crossFieldComparisonVerdict).
Migration. Rewrite each refused predicate the way its lint hint says:
| you wrote | write instead |
|---|---|
record.status != ['closed', 'archived'] | !(record.status in ['closed', 'archived']) |
record.status == ['open', 'pending'] | record.status in ['open', 'pending'] |
record.owner_id != current_user | record.owner_id != current_user.id |
record.f == current_user.positions | record.f in current_user.positions |
record.f in ['a', null] | (record.f in ['a'] || record.f == null) |
record.f > null | record.f != null |
A field compared with a json / multiple field has no pushdown form: compare
with a single-valued column, or move the condition into a validation rule or
hook. Compare a field only with a field of the same class — a number with a
number, text with text, a date with a date — or with a literal or a
current_user value; test a file field with != null; and where the two
columns really hold comparable values, correct the declaration of the one
declared with the wrong type. A using-only policy that relied on writes
landing outside its scope declares a check that admits them. Remove check from every select /
delete policy — AND it into using, or move it to an insert / update /
all policy. A hook that deliberately writes outside the caller's policy does
that write separately under a system context.
New author-time gates. os validate now reports these shapes before they
ship: rls-predicate-unenforceable (the refused comparands above, and — via
the engine's own judge-only judgeFilter — text operators on numbers, dates
compared with unreadable values, filters on formula fields and {…}
placeholder strings, #20265, #20210, #20346), rls-predicate-unknown-field and
rls-predicate-unknown-user-variable (#17036), and security-fls-unknown-field
for a qualified field-permission key naming a field the object does not
declare — a key that never enforced and left the field open (#16998). The
metadata save door now runs the RLS rule on permission writes
(422 INVALID_METADATA); OS_ALLOW_UNLINTED_METADATA_WRITES=1 turns that
refusal
into a logged warning for a migration window.
A field lock or condition that cannot be judged refuses the write
requiredWhen/readonlyWhenfail closed (5dba7f3, #20028, ADR-0137 D2). A faultingrequiredWhenused to be logged "skipped" and the record saved with the field empty; a faultingreadonlyWhenlet the frozen field be written. Both now refuse the insert, by-id update or bulk update withVALIDATION_FAILED/400,code: 'rule_violation'andconstraint.reason: 'unevaluable'. Faulting shapes: a misspelled key (record.statsu), ordering or arithmetic overnull, a read through a lookup (record.account.tier— the field level holds a bare id), and an envelope with no evaluablesource. Migration: fix the key, guard the null operand with!= null, or move a check that reads through a lookup into avalidations[]scriptrule — the one place traversal now works (see New capabilities).os validaterefuses the traversing form up front (e4471e6, #20185).- A
readonlyWhenlock is judged against the row the update will store (#19877, #19905, #19923, #19928, #19979). A caller with edit rights could unlock a frozen field by adding, in the same update, a different parent next to areadonlymaster-detail field, a forged value for a staticreadonlyfield the lock reads, or a value another lock drops.isSystemcallers are bound by these locks too. beforeUpdatehooks see the record the engine intends to persist (d2c1d19, #17327). A caller-supplied value for areadonlyfield is hidden from the before phase; the caller's payload moves to the new frozenctx.submitted. A handler that derived values from a read-only field inctx.input.datareadsctx.previous.<field>instead.- Reference existence checks see only the caller's organization (
afc3b64, #19836, #19854). Alookupid naming another organization's record answersreference_not_found, the same as an id that exists nowhere, closing an existence oracle; a master-detailparentheader in another organization is left unbound. - A cleared number, boolean, date, datetime or time field stores
nullon every backend (c74de10, #20340, #20370). A blank string reached the driver — SQLite stored'', PostgreSQL answered500 DATABASE_ERRORon the ordinary "clear the field and save". Number-type fields now refuse arrays, booleans and objects withinvalid_number, andprogressrefuses non-numeric strings. Stored rows are not rewritten; the changeset gives the SQLite repair query.
Written values are held to the field's declared type
Each entry below narrows what a record write accepts. All but the last are
judged by the record validator on insert, update, a multi-row update and
engine.validate (the dry run), before any driver write, and answered
400 VALIDATION_FAILED with a field code; nothing is stored. Only new writes
are judged — a stored value is never re-read — but an update that sends an old
value back is refused. The last is the /import door's own cell reader.
- A
datestring must lead with a realYYYY-MM-DDday (b2b6a06, #20524;92ea760, #20547)."2026/07/15","07/15/2026"or"15 July 2026"used to be stored verbatim on memory and SQLite — a non-day that falls out of every date filter — and read in the server'sDateStyleon PostgreSQL, and"2026-02-30"was stored as written. Each isinvalid_datenow. - A
datetimestring must be ISO 8601 on a real day (92ea760, #20547):YYYY-MM-DD,YYYY-MM-DDTHH:MM[:SS[.fraction]]followed byZ, an offset or nothing, orYYYY-MM-DD HH:MM[:SS[.fraction]]with no zone — a zone-naive wall clock is UTC."07/15/2026 10:00"used to be read in the server process's zone, and"2026-02-30T10:00:00Z"was stored as March 2. - A
dateordatetimeyear must fall in 0001–9999 (3062e50, #20469). Year 0, a negative year and year 10000 were stored, or answered500on PostgreSQL. - A number field reads a string only by the JSON number grammar
(
2b24b8b, #20496) — onnumber,currency,percent,rating,sliderandprogress.'0x10',' 12 ','+5','.5'and'007'areinvalid_number, and an admitted string such as'12'is stored as the number12on every backend, so abefore*hook now sees a number. The grammar is@objectstack/spec/data'sparseNumericString(b285508, #20414). objectui's CSV import wizard can send the refused forms on its legacy per-row fallback; import through the server/importroute instead. - A declared
precisionbinds (b98fbc2, #20423). Anumber,currency,percent,ratingorslidervalue with more total digits than the field declares is refusedmax_precision, counted the SQLDECIMAL(p, s)way —precision: 5, scale: 2holds999.99and refuses1234.5. It never rounds. - A
progressfield'smin/maxbind (9801da1, #20482), refusedmin_value/max_valueexactly as onnumber. /importreads a comma in a number cell only as a thousands group (fb194c7, #20517).3,14was imported as314and1,5as15, with the import reporting no error. Any comma that does not group thousands — a decimal comma,1,23, or grouping by twos such as1,00,000— now makes the cell that row'sinvalid_number. No locale is guessed.
Migration.
| you wrote | write instead |
|---|---|
date: "2026/07/15", "07/15/2026" | "2026-07-15", or a JS Date |
datetime: "07/15/2026 10:00" | "2026-07-15T10:00:00Z", "2026-07-15T10:00:00+08:00" or "2026-07-15 10:00" (UTC) |
datetime: "2026-07-15 10:00:00+08:00" | "2026-07-15T10:00:00+08:00" |
number: '0x10', ' 12 ', '+5', '.5' | 16, 12, 5, 0.5 — or '16', '12', '5', '0.5' |
/import cell 3,14 or 1,00,000 | 3.14, 100000 or 100,000 |
For max_precision, write a value that fits, raise precision to the digits
the field really holds, or delete it if you meant decimal places — those are
scale. For a progress bound, send a value inside it, or widen min /
max. On SQLite, a numeric column that stored a string as TEXT is found with
SELECT id, "FIELD" FROM "OBJECT" WHERE typeof("FIELD") = 'text'; nothing
rewrites such a cell for you.
Identity, sessions and platform administration
-
Walled deployments take platform-admin standing only from
OS_PLATFORM_OWNER_EMAIL(74832b6, #19136, ADR-0131 D5). Undergroup/isolated, an unscopedadmin_full_accessgrant row no longer confersPLATFORM_ADMIN; the row is left in place, inert. The walled bootstrap no longer picks the oldest grant holder as the Default Organization owner, and a walled rig with no declaration now has zero platform admins, reported aterror.reportLegacyPlatformAdminGrant/resetLegacyPlatformAdminGrantReportare removed from@objectstack/core(and theruntime/plugin-hono-serverre-exports). Migration: put each administrator's verified address inOS_PLATFORM_OWNER_EMAIL(comma-separated) before upgrading. Standing changes are now written to the audit ledger asplatform_admin_standing_change(877dc03, #19194). -
Under
single, first-boot promotion honoursOS_PLATFORM_OWNER_EMAILand requires that owner to be verified (9b9581b). Which user received the grant used to depend on the storage driver's row order. -
sys_account.issueris dropped; better-auth is pinned to exactly1.7.3(9bd4344, #17454). Uniqueness becomes(provider_id, account_id), so two rows that differed only inissuercollide.@objectstack/plugin-authdropsbackfillAccountIssuer,CREDENTIAL_ISSUER,oauthIssuerForand three types, andsys_sso_providerrefuses anissuerchange while accounts are bound to it (409). Migration, per deployment:os migrate account-issuer(read-only; non-zero on collisions) → backup →os migrate apply --allow-destructive→os migrate account-issueragain (expect zero). Colliding rows are never merged for you. Move all eleven@better-auth/*members to exact1.7.3together; read an account's issuer throughsys_sso_provider.issuerbyprovider_id. -
Anonymous
GET /api/v1/auth/get-sessionanswers401 UNAUTHENTICATED(374d9d3, #17881), not200with a JSONnulloutside the route's own schema.client.auth.me()therefore rejects for an anonymous caller:// before const s = await client.auth.me(); if (!s) { /* signed out */ } // after try { await client.auth.me(); } catch (err) { if (err.code === 'UNAUTHENTICATED') { /* signed out */ } }better-auth's in-process
auth.api.getSession()still returnsnull. -
The SDK's auth methods deliver the envelope they declare (
01388fe, #17791, #17237).auth.me/auth.refreshTokenreturned the bare{ user, session }body,auth.login/auth.registernever setsuccess, andauth.refreshTokenrefreshed nothing; a caller reading.data.tokennow reads.data.session.token. -
POST /admin/create-userfollowsmembershipPolicy(344d475, #17443). Underinvite-onlythe account is created with no membership and the response saysmembershipCreated: false. -
organizations.getActiveMember(organizationId)answers for the organization you name (f904e61, #16761), not the session's active one. A non-member now gets403, and an empty id throws before the request. -
Organization reads serve
metadatadecoded (e6c34f6, #19122). The changeset calls this a widening, but the wire type oforganization.metadatachanges from JSON text to an object:JSON.parse(org.metadata ?? '{}')→org.metadata ?? {}.updatedAtbecomes optional onOrganization,MemberandInvitation. -
Shipped permission sets row-scope the SCIM projection tables and
sys_verification/sys_jwks(26550c6, #20023, #20033). Any authenticated member could read every organization's provisioned SCIM users and groups; organization admins no longer read their own organization's either. Grant such reads in your own permission set with a policy naming the rows. -
Delegated administration resolves inside the caller's organization (
a5afe38, #19800, #19859, #19866). Business-unit anchors and position names were looked up by name across organizations undergroup/isolated. -
A principal acting with no active organization holds only its global grants (
f6ceddc, #20540).resolveUserAuthzGrantsread "no organization" as "every organization": each organization-scoped position assignment and permission-set grant the user held anywhere applied. A session falls back to that resolution when it names an organization its owner no longer belongs to, so a removed member kept the capabilities that organization had granted. Migration: act in the organization — select it, or mint the API key from a session that has it active — or grant the permission set globally when it is meant to apply everywhere.buildContextForUserin@objectstack/plugin-securitynow takes the organization to resolve in. -
Smaller identity changes. A
sys_user_position.positionthat names no position in the writer's catalog is refusedreference_not_foundinstead of silently granting nothing (f39ea95, #20292); position rows may no longer spellplatform_admin/org_owner/org_admin/org_member(2a79726, #17436); tenant-admin override on approvals comes only from the capability rung, not a position namedorg_owner/org_admin(917b87e, #18252); and the ADR-0069 auth-gate allow-list matches only at a mount boundary, so a path such as/data/auth/123no longer passes a gated session (cf79182, #17284,4c42fd1).
Analytics answers only what it was asked, for whom it was asked
- The analytics routes check the object read grant (
041d9fd).POST /analytics/dataset/queryaccepts an inline dataset from any authenticated caller, and on a SQL driver its compiled statement ran through the driver's rawexecute()behind the row-scope layer only. The service now asks the new optionalISecurityService.canReadObject(object, context)for the base object and every joined object before choosing a strategy. A deployment with nosecurityservice keeps its old behaviour and warns at init; a registered one that cannot be used denies. Migration: a principal now refused needs object-level read on that object — the grant/dataalready requires. - The saved-report stack is removed (
8d1f7ab, #20125). Its eight routes answer the standard unmatched-route404; nineREPORTS_*/SCHEDULE_*error codes leave the ledger;defineStackrefusesrequires: ['reports']withSTACK_CAPABILITY_UNKNOWN(422). Existing tables are left in place andos migrate planlists them as unmanaged. Migration: delete'reports'fromrequires, theIReportService/SavedReport/ReportSchedulefamily of imports, theSysSavedReport/SysReportScheduleimports, everyclient.reports.*call and the@objectstack/plugin-reportsdependency. Read reports asreportmetadata throughmeta.*, query them throughanalytics.*, and turn a saved ad-hoc query into a list view on its object. There is no replacement for scheduled delivery. - A cube's
publictakes effect, and defaults to visible (f2c7eef, #20348). Nothing readpublic, and it defaulted tofalse. A cube that declarespublic: falseis now left out ofGET /api/v1/analytics/meta, and/analytics/queryand/analytics/sqlrefuse it with404 CUBE_NOT_FOUND, the answer an unknown cube gets. ⚠️os compilewrote the old default into every cube, so an artifact compiled before this release hides every cube it carries.publicis visibility on the analytics API, not row security. Migration: deletepublic: falsefrom every cube meant to be queried, and recompile pre-release artifacts before serving cubes from them. dateRangepresets filter on every backend (0da638c, #17593). 17.4.0 closed the vocabulary, butdriver-memorystill matched every row for twelve of the thirteen presets and both SQL strategies comparedcreated_atagainst the preset's own name — all at200. One resolver now serves every backend, and any array that is not exactly two string bounds is400 ANALYTICS_DATE_RANGE_UNRECOGNIZED. ⚠️ Dashboard numbers that use presets change on upgrade, because the presets now actually filter.- A dataset measure's aggregate must fit its field's type (
357f499, #17559,0252320, #19138).avgover adatetimereturned the average year on SQLite and an error on PostgreSQL. The analytics service refuses every pair theAGGREGATE_FIELD_TYPE_COMPATIBILITYtable refuses with400 DATASET_INVALID, andos validate/os build/os lintreport the same pairs asmeasure-aggregate-field-type-refused. Usemin/maxover temporal fields,avgrather thansumoverpercent, andcount/count_distinctover text, option, reference and structured fields. - The analytics
wheredoor runs the shared comparand rules (14add48, #20008, #20032, #20058, #20115, #20096) — see the next section; the object spelling used to compile{ stage: ['won', 'lost'] }toIN, to= first, or to no predicate at all. - Dashboard widget
chartConfigrefusestype,xAxis,yAxisandseries(8271c81, #19363). A widget is bound to a dataset, yet an authoredyAxis[].fieldcould silently re-point a series. The D2 conversion strips the keys from stored rows, so the widget renders from its dataset selection — axis titles, per-series colours and aseries[].typecombo chart are lost with them. MovechartConfig.typeonto the widget,xAxis.fieldtodimensions, andyAxis[].field/seriestovalues. - Cube joins no longer accept
sql,relationshiporon(5380daa, #18938) — the ON clause is always derived from the foreign key, and an authored condition was silently replaced. Authors who wrote a non-FKsqlshould re-check the numbers that join produced. - Granularity stops at
day(9c44eed, #17893, #17206).second,minuteandhourleaveTimeUpdateInterval; no backend could bucket them, and the in-memory fallback answered a silently wrong200. - Smaller analytics changes. Metric-family widgets (
metric,kpi,gauge,solid-gauge,bullet) take exactly one measure (#18720); ajoinedreport refuses a containerdataset/rows/columns/values/chartand losesblocks[].chart(#20160, #20238);options.stageOrderis accepted only onfunnelwidgets (#17616); an unknowncompareTo.kindis400(#17570); the dataset query parses itsselectionat the door (#17548, #19638); a brokensecurityservice makes analytics refuse rather than run unscoped (5d12b16, #17336);AnalyticsService.queryDatasetno longer registers the dataset's cube by name (#20380); and an ad-hocquery()orgenerateSql()no longer writes the shared cube registry either, so/analytics/metalists configured cubes only — never one a request inferred, nor a suffix measure some caller named (50e273f, #20407;c745e2b, #20433). Author a cube explicitly if something read it from there.
One comparand rulebook at every filter door
The engine, the REST normalizer, the analytics service and every stored filter
carrier used to disagree about the same shapes — one door refused what another
served as a different predicate, or as every row. They now apply one set of
rules. Each refusal is INVALID_FILTER / 400 at run time and
422 INVALID_METADATA (or a parse error) at save time.
- Refused at every query door (
a60c913, #19501, #19882, #20204): an array in the equality slot ({ tags: ['a'] }, empty array included, at any depth), an array under$nein any of its spellings, and a{ $field }reference as a$betweenendpoint. Ondriver-mongodbthese used to get MongoDB's own array semantics — re-check what each rewritten query should return. - A
wherethat is not a filter object or condition array (949e99b, #20144). A string, number,Map, boolean orDatewherepassed every check and the driver ignored it:findanswered every row, andupdate(…, { multi: true })/delete(…, { multi: true })rewrote or deleted every row, under a system context included. - A text operator on a column whose declared type can never hold a string
(
a54ecaa, #17381, #17345).$contains/$startsWith/$likeand friends on a numeric, boolean, temporal or structured field answered[], every row, or whatever the dialect did. The engine now refuses them, naming the field, its type and the operator; below the engine, the SQL drivers compile the temporal classes to the type-gated no-match instead of a SQLite substring match or a PostgreSQL500. havingand per-aggregationfiltergo through the same doors aswhere(aa04ea2, #20097, #20117, #20147, #20174, #20202, #20307), once per query and before any driver call — so an empty table no longer answers200where a populated one refuses. Ahavingkey must name a projection or alias, and a string or array aggregationfilter— which used to be dropped, so the aggregation read every row — is refused.- Stored filters are judged at save (
6aa3188, #20047, #20207, #20247, #20325).FilterConditionSchema, and so every carrier — dataset and measurefilter, dashboard widgetfilter, reportruntimeFilter,relatedListFilter, rollupsummaryOperations.filter— refuses at parse what the query faces refuse. Stored rows keep loading; their next save is refused.POST /analytics/dataset/queryandPOST /analytics/queryanswer these shapes400 VALIDATION_FAILEDwhere they answered400 INVALID_FILTER. The Console's filter widget wrote "is empty" as$in: [null, ""]; those Studio saves are now refused. - Temporal comparands (
615c468, #20224, #20261). A number orDatecompared with adatefield is its UTC calendar day on every face (it selected 0 rows on memory, 6 on SQLite and a500on PostgreSQL). Adateordatetimecomparand whose year falls outside 0001–9999, year 0 included, is refused onwhere, a per-aggregationfilterandhaving(3062e50, #20469). - A number field is compared with a number (
4a1df19, #20501;b057434, #20545). A string the platform's numeric grammar does not read ("abc",""," 12 ","0x10","1,000", a{placeholder}), a boolean, aDateor an array compared against a numeric field — or a numeric aggregate inhaving— answered, by value and driver, no rows, every row, a driver's own refusal or a PostgreSQL500. It is nowINVALID_FILTER/400before any read, naming the field, its type and the comparand. A numeric string such as"12"is narrowed to its number, sodriver-memoryanswers it as SQLite and PostgreSQL already did. The contract is published from@objectstack/spec/data(numberComparandDoorVerdict,b285508, #20414). havingresolves{placeholder}tokens throughwhere's resolver (2f122b6, #20368). An unknown token used to be compared as its own text and keep no group; it is nowFILTER_TOKEN_UNKNOWN/400, and a context token the request cannot fill isFILTER_TOKEN_UNRESOLVED/400. A known token such as{current_year_start}now compares as its value. Refusals inside a per-aggregationfilternameaggregations[i].filter, notwhere.$betweenneeds two present, non-blank endpoints (176b035, #19066,32b5831).count/count_distinct/sum/avganswer JS numbers on PostgreSQL and MySQL (15bf186, #20372) — they came back as strings, sohaving { n: { $in: [2] } }kept no group. Code that compared them as strings treats them as numbers.sumover a fractional column and everyavgnow also accumulate in double there, as on SQLite, and the engine's in-memory rows path adds with SQLite's compensated summation (fc0db22, #20486;8538edf, #20543):0.1 + 0.2is0.30000000000000004on SQLite, PostgreSQL, MySQL and the rows path alike, so compare a fractional sum with a range, not$eq. On MySQL this needs 8.0.17 or later.
Migration.
| you wrote | write instead |
|---|---|
{ stage: ['won', 'lost'] } | { stage: { $in: ['won', 'lost'] } } |
{ stage: { $ne: ['won', 'lost'] } } | { stage: { $nin: ['won', 'lost'] } } |
{ stage: { $in: [null, ''] } } | { $or: [{ stage: { $null: true } }, { stage: '' }] } |
{ amount: { $gt: null } } | { amount: { $ne: null } } (or $eq) |
{ $between: [1, ''] } | { $between: [1, 100] }, or { $gte: 1 } |
{ amount: { $contains: '500' } } | { amount: { $eq: 500 } } |
{ created_at: { $startsWith: '2026' } } | { created_at: { $gte: '2026-01-01', $lt: '2027-01-01' } } |
where: 'amount > 100' | where: { amount: { $gt: 100 } } |
"$null": "true" | "$null": true |
{ amount: { $gt: '1,000' } }, { amount: { $gt: true } } | { amount: { $gt: 1000 } } — the number the filter means |
having: { last: { $gte: '{TODAY}' } } | a token the resolver knows, such as '{today}', or the literal value |
The data engine declares what it returns
findOne,updateanddeletedeclare their real return types (854639b, #17255). They werePromise<any>and are nowPromise<Record<string, any> | null>, a record-or-count-or-null, andPromise<boolean | number>— TypeScript that reads a field offfindOnewithout a null check stops compiling. At run time a value outside the shape, from anafter*handler or a driver answering outsideIDataDriver, is refused with a500and a registered code (FIND_ONE_HOOK_RESULT_NOT_RECORD,UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE,DELETE_HOOK_RESULT_NOT_WRITE_SHAPE).SqlDriver/TursoDriverandIScopedObjectRepository.updateByIdpublish typed results the same way (#17258, #17836).ctx.engine.find(object, query)in an action handler takes the query envelope (7d0f911, #19223). The second argument used to be thewherehalf only, so a real envelope reached the engine as{ where: { where: … } }and returned[]with no error.ctx.engine.find('task', { status: 'open' })→ctx.engine.find('task', { where: { status: 'open' } }). Untyped handlers get a runtime refusal that lists the envelope keys. Re-run each migrated handler against seeded data and check the row count.engine.registerHookthrows for the six lifecycle events the engine never dispatches (54e8234):beforeFindOne/afterFindOne→beforeFind/afterFind;before/afterCountandbefore/afterAggregate→engine.registerMiddlewarecheckingctx.operation. Read filters onbeforeCountnever narrowed list totals.multiple: trueis refused outside the multi-capable field types (d285bf0, #18187):{ type: 'text', multiple: true }→{ type: 'tags' };master_detailwithmultiple→lookupwithmultiple.- An object with a field whose
typeis missing or not aFieldTypeno longer loads (2bed4c3, #17444). One declaration used to produce two different columns; at boot such asys_metadatarow does not register and is logged aterror. - Currency takes its decimals from the currency (
5b9402d, #19909, #20223, #20251).scaleis refused oncurrencyfields and currency inline columns, andcurrencyConfig.precisionis removed — do not move it to the field-levelprecision, which is total digits. - Numeric columns get one type across all three DDL producers (
9cdffbe). Newnumber/currency/percent/slider/summary/progresscolumns arenumeric(65,30)and newratingcolumns areinteger— no existing column is retyped. Aratingcolumn on PostgreSQL refuses4.5(usesliderfor fractional ratings), and generated migrations no longer emitNOT NULLforrequired: truealone: writestorage: { notNull: true }.
An edge-branched decision takes its first matching branch
A decision node that declares no config.conditions and branches on its
out-edges used to take every out-edge whose condition held, one after
another, while its schema and docs called it an exclusive gateway
(0283cb9, #20344). It is now exclusive, and mode: 'inclusive' is how a
decision asks for every true branch:
an edge-branched decision with no conditions | 17.4.0 | 17.5.0 |
|---|---|---|
no mode, two conditioned out-edges both hold | both successors run, sequentially | the first declared one runs; the second records a skipped step |
config: { mode: 'inclusive' } | accepted, never read | every out-edge whose condition holds runs, sequentially |
| no condition holds | the isDefault edge runs | unchanged |
mode beside a non-empty conditions list, or not exclusive / inclusive | refused by a direct parse only | refused at registerFlow and by os validate |
⚠️ A flow stored in sys_metadata is not rewritten and silently takes the
new meaning: nothing about a stored row says it was saved before the flip,
so a decision the Studio designer saved runs first-match from the upgrade on.
Migration. os migrate meta --from 17 writes mode: 'inclusive' onto
every authored decision with no conditions and two or more conditioned
out-edges, so a migrated flow runs as it did. Then review each written key:
delete it where the conditions partition (== 'a' beside != 'a', a guard
beside isDefault: true), and keep it where the flow relies on more than one
branch running. os validate reports flow-decision-inclusive-overlap on
every decision that keeps it, so the review list is the lint output. For the
stored rows, os migrate meta --stored lists every such node under
decisionModeReview and writes nothing for it; add
config: { mode: 'inclusive' } to each one that meant every branch.
Flows refuse what they would have run as a silent false
A theme runs through this release's flow changes: a slot that parsed, registered
and then evaluated to a silent false — a branch never taken, a trigger never
armed, a run paused forever — is now refused where it is authored. ⚠️ A flow
already stored in sys_metadata with one of these shapes stops registering at
boot, its trigger is never armed, and the only signal is a warn line
beginning [Automation] failed to register flow that names the node and slot.
Sibling flows still register. Check boot logs after upgrading.
- A
waitnode must say what resumes it (cb1f274, #18175, #18370).waitEventConfigis required, and a timer needs a usabletimerDuration; such a node used to suspend withsuccess: true, arm no job and staypausedforever. AddwaitEventConfig: { eventType: 'timer', timerDuration: 'PT1H' }(orsignal/webhook/manual/condition) and re-publish. - Blank and absent predicates are refused. A whitespace-only node
config.condition(92865f6, #17491), a blank decisionexpressionor screenvisibleWhen(2c1011b, #19960), a decision branch with noexpressionat all (16c5473, #20315) and anast-only or blank edgecondition(53ec0b1, #17267). All 36 engine-evaluated expression slots now require a non-blanksource(ce57857, #18638);printCelAstin@objectstack/formularecovers source text from anast. ⚠️ Removing a condition inverts the node — an absent condition always fires — so writeexpression: 'false'where you want the branch kept but never taken. Do not delete a decision's only branch. - A node config its executor cannot run is refused (
7dc45eb, #20416;2304b16, #20453). A builtin node that leaves out a key its executor contract requires —objectNameon the record nodes,recipientsonnotify(andtitlewith notemplate),urlonhttp,functiononscript,flowNameonsubflow,collectiononmapand on aloopwith abody, a screen field'sname, alookupscreen field'sreference— used to register and then fail on every run that reached it. Adecisionbranch with nolabelnever failed at all: traversal took every out-edge. Aconnector_actionnode with noconnectorConfigblock, or a blankconnectorId/actionId, failed at the executor's guard. All of these are now refused at parse,registerFlowandos validate, at any depth. The Studio flow designer writes such shapes when a node is saved before it is configured. Migration: write the key the node was meant to carry —config: { objectName: 'account', … }, a branch{ label: 'large', expression: … }beside an out-edge labelledlarge,connectorConfig: { connectorId: 'slack', actionId: 'chat.postMessage' }— or delete a connector node you cannot configure yet. - An
apiflow needs its per-flow secret (487a784, #20551). A flow bound to theapitrigger whose start node carries no non-blankconfig.secretused to arm its inbound hook with only a warning, and the hook skipped signature verification. It is now refused at registration —400 VALIDATION_FAILEDon the/automationdoors, skipped at boot — and at arm time. Migration: give the start node aconfig.secretand sign each post with it (x-objectstack-signature). A flow that is only ever started explicitly istype: 'autolaunched'and needs none. - A structured region body refuses
screen,wait,approval,approval_reviseandend(7843663, #18688). A region runs synchronously inside its run, so it can neither pause nor end it. Move the node onto the top-level graph:loop { body: [ …, end ] }→loop { body: [ … ] } → end. - Screen fields gain
min/max,inlineHelpTextandreference(2f1a6f6, #17913) — and alookupscreen field now requiresreference, while a non-number value for anumberfield is refused on resume. create_record/update_recordfield values accept the CEL value envelope (e462186, #20205). A{ dialect: 'cel', source }value used to be written into the record verbatim; a malformed envelope-shaped value is now refused. To store an object carrying a stringdialectkey as data, bind it to a flow variable.- Resuming a paused run checks who is resuming (
b81da66, #20005). Any authenticated user holding another user's run id could continue it, and its data nodes then ran as the starter. The caller must be the run's starter, a reader ofsys_automation_run, or a system context. - A connector's
retryConfigandrequestTimeoutMsare executed (b929e0a). They parsed and did nothing; a connector that declares a policy now retries per it, andconnector-openapi's generated actions gain the 30 s per-attempt timeout and bounded retry the other connectors had.connector.connectionTimeoutMs— which no provider ever applied — is removed (fc29c74, #19657). - Connectors lose
health,statusand the nestedwebhooks(40b315b, #20350) — sixteen keys, the health probe and circuit breaker included, that nothing read. Each is refused at parse with a prescription; stored connector rows are stripped on read, andos migrate meta --from 17lists the source edits. Migration: delete the three keys. A nested webhook was never delivered, and moving it to the stack's top-levelwebhooks:starts deliveries, so decide per webhook.ConnectorHealth,HealthCheckConfig,CircuitBreakerConfig,ConnectorStatus,WebhookConfig,WebhookEventandWebhookSignatureAlgorithmare removed with no replacement. - A webhook whose credential exists only as cleartext in
sys_webhook.definition_jsonstops delivering (9a0c0b5, #19944), and any write carryingsecretorheadersthere is refused. Register aCryptoProvider, then restart somigrateLegacyWebhookSecretsmoves the values into the encrypted columns — there is no path without one. - Smaller flow changes.
CronSchedule.timezonemust be an IANA zone (839d1b0, #19183);defineStackrefuses an auto-launched flow whenrequiresliststriggerswithoutautomation(#20365); a durable pause inside a region fails the run with a named refusal (#19140); and a screen decides which fields the caller supplied from the newAutomationContext.callerParamKeys, so a required field namedrecordIdor<object>Idis now collected interactively (#19899).
MCP and AI agents
-
An OAuth-connected agent reads at its delegating user's record depth (
331a1a2, #17332). A capability ceiling that declares no depth used to answer'own', so an agent acting for aviewAllRecordsuser saw only own and shared rows — silently. The user's depth now stands; a ceiling that declares a depth still narrows, and MCPquery_recordsreturnsdelegationNarrowed: truewith awarningwhen it did. -
MCP tools refuse undeclared argument keys (
46cf705, #17529). All eleven tools stripped them, soquery_recordswith"sort":"-amount"answered in seed order and with"filters":[…]answered the whole unfiltered set. The refusal names the key and the closest declared one:tool wrong spelling send instead query_recordssort/sortBy/orderorderByquery_records,aggregate_recordsfilters/filter/conditionswherequery_recordsselect/columnsfieldsquery_recordspageSize/top/takelimitaggregate_recordsmetrics/aggregatesaggregationsevery object-scoped tool object/tableobjectNameget_record,update_record,delete_record,run_actionid/record_idrecordIdcreate_record,update_recordrecord/valuesdatarun_actionargs/input/parametersparams -
ai.requiresConfirmation: trueis enforced (76ddab7, #17486). An unconfirmed MCPrun_actionon such an action answers428 ACTION_CONFIRMATION_REQUIRED; sendconfirm: trueafter confirming with the human. REST/actionsis outside the gate. -
Machine-to-machine (
client_credentials) OAuth tokens are refused on MCP (45c2cf9, #17441). Such a token ran as an authenticated member and stamped a non-user id intocreated_by/ owner columns. Use the headless API-key track (x-api-key, orOS_MCP_STDIO_API_KEYover stdio). -
The RAGFlow knowledge adapter reads
source.adapterConfig, notsource.options(cb005e0, #19251) — movedatasetId,rerankModel,similarityThresholdandvectorSimilarityWeight.
Views, pages and actions
- A view with no declared page size shows 50 rows (
8ecbe0f, #20184).PaginationConfigSchema.pageSizedefaults to50(was25), which is also the fetch ceiling of a view with no pager (kanban, gallery, timeline). Writepagination: { pageSize: 25 }to keep 25. - Form
layoutaccepts only'vertical'or'horizontal'(569d4d2, #20262). Every renderer turnedinlineandgridintovertical; the D2 conversion rewrites stored rows. Usecolumns: Nfor a multi-column form. - List-view
sorttakes only the array form (1e20f81, #17914, #17439):sort: 'created_at desc'→sort: [{ field: 'created_at', order: 'desc' }]. A lossless conversion rewrites author sources and stored rows. - Filters on pages and
object-*blocks take only theViewFilterRulearray (4792049, #17257):filter: { status: 'active' }→filter: [{ field: 'status', operator: 'equals', value: 'active' }]. Stored page filters are converted on read where the conversion is lossless, andos migrate meta --storedlists the ones it cannot convert (#20175, #20244). View filter rules also refuse an array on a scalar operator and a missing value on a value-taking operator (2b52a5b, #19750, #19861). - View items and overlays refuse
ownerandhidden(3cb84d0, #20227, #20286). Nothing read them:hidden: truehid nothing, and a view with anownerwas listed for everyone. A save carrying either is422 INVALID_METADATA; stored rows are stripped on read. - List views drop
type: 'page'andpageName(d4f5232, #17298) — no renderer ever routed them. Reach the page through an app navigation item. - A list view's own
tabsis retired (6e3e546, #20357). It parsed, was stored and was drawn by nothing: the tab strip above an object's records is itslistViewsswitcher. Migration: deletetabs:from every list view and add onelistViewsentry per tab users should switch to — the tab'snamebecomes the entry's key, itslabelthe entry'slabel, itsfilterrules join the entry'sfilter, and the view'scolumnsare copied across. Storedviewrows are stripped on read, but an object's ownlistViewsis reached by no conversion and is refused until edited. The page list'suserFilters.tabspreset bar is a different key and stays. ariaon an action is refused (dcd3bce, #20398); no action renderer ever applied it. Put the accessible name in the action'slabel, which every action renderer announces, and describe the toolbar or list that places the actions in that node's ownariablock (page.components[].ariaor the list view'saria). Stored rows are stripped on read.page.assignedProfilesis removed (57343f7, #17835). It never gated anything; gate the data with permission sets bound through positions.undoable: trueis refused unless the action usesoperation: 'update'ortype: 'api'(6afa59d, #19635) — the only two shapes that ever produced an Undo.- Bulk-action params are strict (
adabccf, #19090) and declaredependsOn;helpText→help,defaultValue→default,reference→object,displayField→labelField, and the widget-config keys are removed. - A view container whose
objectnames no object failsos validate(ae8e3ca, #20253) — commonly a missing namespace prefix, which the hint names. - Smaller view changes.
object-kanbanlosesquickAdd(#17792);ListViewSchema.navigation.viewis removed (#18619); chart config losesaria— the accessible name comes fromdescription(#18300);groupByField/grouping.fields[].fieldrefuse surrounding whitespace (#18695, #17498); and a stored view's console round-trip keys (isPinned,sortOrder,visibility,_isOverride) are declared, so a parse keeps them and theviewsave door refuses a mistyped value (e967cbd, #20474).
Packages, manifests and the install doors
manifest.idmust be reverse-domain notation (097d268, #18319). The sharedMANIFEST_ID_PATTERNis dot-separated lowercase segments, hyphens allowed inside a segment, no underscores — somy_appandcom.acme.my_appare both refused.os init my-appused to generatecom.example.my_app, which built and booted and was then refused at publish;os initandcreate-objectstacknow generatecom.example.<kebab-name>. An assembled package withmanifest.id: '', which used to load with no consent record bound, is refusedINVALID_ARTIFACT_PACKAGE_ENTRY. The install-local, protocol install and duplicate doors apply the same pattern (#20022, #19574, #19829). Migration:com.acme.my_app→com.acme.my-app. Changing an id is a republish, not an edit — confirm no registry entry, installed row or dependent still refers to the old value.manifest.namespacekeeps allowing underscores.POST /api/v1/packagesparses its whole body (13d5294, #19326, #19473, #20218, #20277). It used to read the manifest positionally and answer201for almost anything. Nowversionmust be present and semantic,nameandtypeare required, unknown keys are refused — including a misspelled top-level option:{ manifest, enabledOnInstall: false }used to install the package enabled — andenableOnInstall/overwritemust be JSON booleans ("false"used to install enabled).- Re-installing keeps a package's current enabled state unless
enableOnInstallis sent (4fef271, #19291, #19338, #19690). A re-install used to re-disable a package an operator had re-enabled; the flag now sets the state in both directions, and an absent flag parses toundefined("keep"), nottrue. An upgrade flow that relied on a flag-absent re-install to clear a disable sendsenableOnInstall: true. - Every
versionkey uses one SemVer 2.0.0 grammar (2cac363, #19637). Prerelease and build suffixes are accepted everywhere (os plugin buildrefused2.0.0-beta.1that the publish door accepted), and non-SemVer forms are refused — includinglatest,v1.0.0and1.0onPackageManifestSchema.version.isSemverShapedVersionis renamedisSemverVersion. defineApp({ hidden: true })in an artifact means hidden, not unpublished (134b410). An app that was withheld from every user without Studio / Setup access purely because of this becomes visible on the next boot —hiddenis navigation presentation, never an access gate.- A multi-package artifact stores each definition once, inside its
packages[]body (6057357, #19666). A tool that reads the top-level collections of a compiled multi-package artifact iteratespackages[]instead. @objectstack/spec/cloudis removed (776d64c, #17372). The package and marketplace format moves byte-identically to@objectstack/spec/marketplace; the cloud control-plane contracts (EnvironmentSchema,TenantPlanSchema, …) leave the open-source spec with no replacement.- Assembled-stage Package API declarations move to
@objectstack/spec/api-assembled(c23cfb3, #20052) — theAssembledInstalledPackage*,InstalledPackageAtEitherStage*,ListInstalledPackagesResponse*/GetInstalledPackageResponse*families andPackageApiContracts. - The metadata migration chain starts at protocol 16 (
f20fe29, #19302).os migrate meta --from Nrefuses N from 10 to 15. - The declared
zodfloor moves to^4.6.1(95fb417, #19658) — below it, zod's error formatters crash on an issue path naming anObject.prototypemember, which the spec's own__proto__refusal emits.
REST and the client SDK
GET /api/v1/automationandclient.automation.listare removed (3875ae6, #20192, #19493) in favour ofGET /api/v1/meta/flow(client.meta.getItems('flow')). The run list dropscursor, computeshasMoreby reading one extra row, andListAiConversationsResponserequireshasMore.- The export-job API family,
IExportServiceandScheduleStateare removed (4db1bf1, #20194) —GET /api/v1/data/:object/exportis the export door, and aJobwithschedule.expressionthe recurring one. - Metadata read doors apply the plain read's per-caller gates.
/layers, the anonymous-reachable?layers=true,/published,/diff,/historyand/auditserved gated doc and book bodies, unpublished or permission-gated apps and unmasked object fields (585c9af, #20190, #20284, #20337); the dispatcher-only hosts (@objectstack/hono'screateHonoApp) applied no gate at all on/metareads (2bcd5cf, #20236, #20319); and pending drafts were served to any signed-in caller (5049a3c, #20373) — they now needstudio.access,setup.accessormanage_metadata. /diff,/historyand/auditon a metadata item are authoring doors (7fa3e3e, #20440;8e02859, #20472). Any signed-in caller who could open an item used to read its unpublished draft through/diff, its draft saves through/history, and who saved a draft and when through/audit. A caller withoutstudio.access,setup.accessormanage_metadatanow gets403 FORBIDDEN, the same answer for an item that exists and one that does not, soclient.meta.getAuditcalled as a member rejects. The layered view (/layers,?layers=true) answers a name with no layer behind it with the plain read's404 RESOURCE_NOT_FOUNDinstead of200with every layernull(b43a814, #20527).api.documentationshapes the served OpenAPIinfo, and itsversionis retired (80153f5, #20512).title,description,termsOfService,contactandlicensewere parsed and never served; they now overlayinfoon both OpenAPI doors,contactandlicensereplacing the bundled object whole.api.documentation.versionis refused byRestServerand the REST plugin —info.versionis always the protocol version — so write your app's own release number intodescription.documentation.titleno longer defaults to'ObjectStack API'.GET /meta/:type/:nameanswers an absent name with one body (4d2008c, #18395, #18691, #18655):404and a nestederror.code: 'RESOURCE_NOT_FOUND', where the uncached arm answered200withoutitemand the cached arm a flatcode. Readbody.error.code.- Numeric query parameters that cannot be read are
400 VALIDATION_FAILED(95ab93f, #20137, #20345).?limit=abcexported one row, returned a whole change log or removed search's cap. The client SDK now sends everylimitas written, solimit: 0can answer400— leave it out for the server default (#20060). - A sandboxed hook or action body that crashes answers a sanitised
500(cf6e0a1, #17228, #18533), not its declared4xxwith the QuickJS crash text, and not400on/api/v1/actions. Retry policies and alerting that treated these as client refusals now see5xx. POST /data/:object/querydeclares its transport spellings (0b788da, #18704) and refuses five body shapes it used to serve:{ $orderby: 'name desc' },{ sort: '-created_at' },{ $orderby: ['name'] }, and a JSON string on$filter/filters/filter. Send{ $orderby: { name: 'desc' } }and a filter object.- A rate-limit budget refuses unknown keys wherever it is mounted
(
fb2bccf, #18861). Onapis[].rateLimit,windowSeconds: 60parsed and then metered the 60000 ms default; usewindowMsandmaxRequests. - Smaller SDK and REST changes.
client.packages.get/listreturn the bare installed-package row (#17419, #19323);oauth.applications.registertakesclient_nameand a space-delimitedscopestring (#17209, #17834);environments.updateVisibilityis removed (#18513);CONCURRENT_LIMIT_EXCEEDEDleavesStandardErrorCode(#19957);api.responseFormatandapi.documentation.enabledare removed fromRestServerConfig(#20343);insertManyDatareportsdroppedFieldsonce per batch (ada2869); protocol refusal messages stop opening with a bracketed code — readerror.code(#19683); andPackageApiContractsdrops three entries for routes nothing served (#19937).
Drivers
- A remote-mode
TursoDriverrefuses what it cannot deliver (62bce5c, #18717, #19842, #19891, #20014, #20073, #20104, #18890). Transactions, deferred DDL, drift detection, media column-move planning and 23 inheritedSqlDrivermembers silently did nothing or answered from a placeholder in-memory database — writes between begin and rollback were already durable, andos migrate applyran DDL while reporting none. They now throwNOT_IMPLEMENTED/501, andos migrate plan/applyexit non-zero against remote Turso. A driver can declare the newcapabilities.transactionsUnsupported; on itengine.transaction()warns and runs without a transaction, and{ require: true }throws. Remote mode no longer needsbetter-sqlite3. Migration: use the local or embedded-replica transport for atomic work, and runos migrate planagainst a localfile:copy. TursoDriverrefuses at construction a url it cannot open (0142415, #19971, #19996, #20199). A bare path, a remote url besidesyncUrl, or an in-memory replica all ran on a private:memory:database whose writes vanished on restart.url: './data/app.db'→url: 'file:./data/app.db'.TursoDriveralso refusessyncUrlunder a forcedmode: 'remote', andsyncwith nosyncUrl(bea6d2e, #20447), withVALIDATION_ERRORbefore any client opens. Both were built and ignored whileisSyncEnabled()answeredtrue. A stored datasource row with either shape now fails when its driver is built, and under ADR-0062 D5 the boot fails fast when objects bind to it or it is boot-critical. Migration: for a remote database dropsyncUrlandsync; for an embedded replica writeurl: 'file:./data/replica.db'with the remote insyncUrland nomode.- Remote-mode Turso refuses a missing table or column as local mode does
(
3e8b492, #20461):DATABASE_ERROR/500for an absent table, andINVALID_FIELDorINVALID_FILTER/400for an absent column, where most of those reads answered[]ornulland schema drift read as "no data". Run schema sync, or name a column the table has. Remote mode now also reads a federated object'sexternal.remoteNametable, which it ignored, and refuses anexternal.columnMapthat renames a column withNOT_IMPLEMENTED/501(dbddf02, #20422); use the local or embedded-replica transport for such an object. - A declared index that can never be built is an
error, and drift reports it (c7ad16f, #20519). An index naming a column that is not a field, or aformulafield, was skipped at every sync with awarnand left out of drift, so auniqueindex silently enforced nothing. The skip is now logged aterror, andos migrate planlists the index under "Needs confirmation" as the newDriftOpmemberunbuildable_index, whichos migrate applyreports skipped; an exhaustiveswitchoverop.typegains a case. A database that already has such an index shows one entry per index until the metadata names stored fields or drops it. driver-memoryrefuses a tenant-scoped call (555a89c) instead of discarding the scope and returning every organization's rows. The changeset states that every isolation measurement previously taken on the memory driver is void. Usedriver-sqlon:memory:for organization-scoped development.- ADR-0104 file columns can move to the bare
sys_fileid (77c801e, #17403,fe71032).os migrate files-to-references --applygains the column step on PostgreSQL and SQLite; a deployment that does not run it keeps its storage. - On MySQL a
DATEcolumn reads as itsYYYY-MM-DDtext (d3958ba, #20306); rawexecute()callers receive a string instead of aDate. $contains/$notContainson a multi-valued or JSON column is a membership test on every dialect (e04a0af), no longer a substring match over the serialized array.
Runtime, messaging, storage and translations
- Idle pollers back off to a 30 s ceiling (
690f083, #17622, #17632, #18134).NotificationDispatcher,HttpDispatcherandDbQueueAdapterpolled every 0.5–1 s against empty tables. They now double their delay after every empty tick up tomaxIdleIntervalMs(default 30 s) and wake at once on in-process work. Work written by another process or node can be noticed up to 30 s later — HTTP claim recovery moves from about 5.5 s to about 35 s. To keep a fixed interval, setdispatchMaxIdleIntervalMsequal todispatchIntervalMsonMessagingServicePlugin, andDbQueueAdapterOptions.maxIdleIntervalMs≤pollIntervalMs. sys_notification_deliverydeletesdeadandsuppressedrows after 7 days, not 90 (e7fea46, #17871). Reports, SLA readings and investigations that read failed deliveries move inside the 7-day window. For this object theretention_overrides.maxAgesetting now moves only the terminal-failure window;expireAftermoves the whole table.- The S3 storage adapter requires
keyPrefix(6ff5b56, #17599). PasskeyPrefix: nullto keep bucket-root keys byte-identical; a string prefix confines every door to that namespace, and changing it is a store move. - Eighteen kernel and system duration keys carry their unit in their name
(
98bd798, #17986, #17983, #17999, #18007, #17954, #18016), finishing the 17.4.0 wave:PluginHealthCheck.interval→intervalMs,HotReloadConfig.debounceDelay→debounceDelayMs,RuntimeConfig.resourceLimits.timeout→timeoutMs,MetricsConfig.collectionInterval→collectionIntervalSeconds,SchemaLevelIsolationStrategy.performance.schemaCacheTTL→schemaCacheTtlSeconds, and the logging, tracing, metrics-export and OpenTelemetry-exporter keys with them. Values are unchanged; each old spelling is a tombstone that names the rename.FileValue.duration→durationSecondsrides along (#19549). - Per-app translation bundles and
translationmetadata items may no longer carrysettings(d0f1845, #19600, #19945). Settings copy is platform-only; an app'ssettingsstrings used to show only where the platform bundle had a gap, and those screens now show the platform string or the manifest's English literal. Stored items are converted on read. Platform packages type their bundles with the newPlatformTranslationData. - An orphaned locale key fails the build (
c88fa2c, #17777):translation-target-unknownbecomes anerror, so a key that resolves to nothing failsos lint,os validateandos build. Four accompanying fixes stop it flagging keys for contributed navigation,objectExtensions[]fields and sibling packages (#18433, #19060, #19347, #19625). Delete each key the finding names, or re-key it to the renamed target.
Author-time refusals that can fail a stack which built clean on 17.4.0
Every retirement above is a parse-time refusal naming the key. Beyond them,
os validate / os build / os lint gain checks that turn a green build red
on metadata that ran — often wrongly — on 17.4.0:
-
os validateandos buildrefuse a config whose default export was not built bydefineStack(...)orcomposeStacks(...)(ba5927f, #20460), withSTACK_PROVENANCE_MISSINGand exit 1, before any other check — and so doesos dev, which compiles throughos build. The stack-level refusals (STACK_CAPABILITY_UNKNOWN,STACK_CROSS_REFERENCE_INVALID, …) run only insidedefineStack, so a plain-object export passed both commands and shipped. Migration: wrap the export, moving any key spread onto a copy into the call:// before export default { ...defineStack({ manifest, objects }), api: { /* … */ } }; // after export default defineStack({ manifest, objects, api: { /* … */ } });For a composition, wrap each input:
composeStacks([defineStack({ … }), …]). A config that passed can then fail with one of the stack family's own codes; those findings were always there, and the plain export hid them. -
object-field-ref-unknownnow judges a field'srelatedListColumns,lookupColumns,lookupFilters[].fieldanddependsOn, and an object'sindexes[].fields(4b2d904, #20479):errorinos validate,os buildandos lint, and422at the runtime publish door on an object write. A misspelt name used to surface only when a user opened the view or picker, and a misspelt index column made the SQL driver skip the whole index, auniqueone included, with only a warning. -
os validateandos buildrefuse aviews:container whose ownnamedisagrees with the object it binds to (c5d6b2b, #20391;acd0095, #20459) — a stack the server already refused at boot, and whichos buildused to write into an artifact. Remove thename, or set it to the object name. -
os validateandos lintnow read a project that declares its metadata only inpackages[](ADR-0130 option B), and run the per-package rule passos buildruns (edaf3b2, #17524, #17775, #17822, #17902, #18769, #18813). On such a project all 44 author-time rules used to report nothing and--scoreanswered100/100 (A). Runos buildon the same tree to see what it already reported. -
object-reference-unknownrefuses alookup/master_detail/userfield whosereferencenames no object in the stack, the artifact or the platform (f89dd33, #17066) — it built clean and failed at run time with404 OBJECT_NOT_FOUND. -
The runtime publish gate runs the author-time rules on
dataset,action,hookandreportwrites (58644ad, #19272, #19517, #19596), so a Studio, REST/metaor MCP publish that used to succeed can answer422 INVALID_METADATA. -
os buildcollects package docs from each package directory of an ADR-0130 layout and checks their names against the owning package'snamespace(df0c856, #18962, #18428, #18445, #19492). -
Smaller rule additions:
rollup/non-numeric-aggregand(#17012),list-view-field-unknownon five more keys (#18833, #18860),action-name-undefinedonrecord:alertandpage:header(#20171),filter-preset-comparandon pagefilterByandlookupFilters(#19818), preset date names oncreated_at/updated_at(#17430), the reserved-vocabulary rule on field-group headings and at the publish gate (#18850,a227afa), import mappings whosetargetnames no field (#20208), flow template tokens rooted at aget_recordoutput (#18583), and malformedpackages,objectsand collection shapes refusedSTACK_SCHEMA_INVALID/INVALID_ARTIFACT_PACKAGESinstead of dropped (#19794, #19783, #20228, #20231), andcomponent-props-invalidon a wrong-typedproperties.objectbeside adataSourcebinding, advisory unless--strict(d753744, #20454).
Smaller breaking changes in 17.5.0
ObjectSchema.fieldsrefuses__proto__,constructorandprototypeas field names (b1d3945, #19147,b3615f1).os generate schemais retired (f289f2b, #20266, #17903) — useos validateand the per-type JSON Schemas under@objectstack/spec/json-schema/.- Machine output calls the protocol version
protocolVersion(cca1dc0, #17261, #17240):runtime→protocolVersion,specVersionGap→protocolVersionGap, andOS_PROTOCOL_INCOMPATIBLE'sruntimeVersion→protocolVersion. os package publishuses a declaredmanifest.idor refuses it (0aa88eb, #17530) instead of substituting a derivedlocal.…id.objectstack.config.tsnamed exports that duplicate the default export are reported instead of silently dropped (f32f480, #18647, #18416).- Seven cron-typed positions nothing evaluated are deleted (
929d9e3, #17146, #17638) — export schedules,ScheduleState.cronExpression, connectorsyncConfig.schedule, cache warmup (CacheWarmup.strategy: 'scheduled'included) and backup / DR-test schedules.Job.schedule.expressionis the one cron slot the platform evaluates. - The CEL options of
ServiceLevelIndicator.successCriteriaandTraceSamplingConfig.composite[].conditionare removed (ee5812a, #19084); both take only their structured shape. - The plugin-security scan-result surface and the startup-orchestrator types
are removed (
744a0a3, #19610;74eaab8, #18303);PluginStartupResultnow matches what the kernel returns. - Hono UI auto-discovery mounts only
type: 'ui'plugins (3c48234, #20086); the legacyui-pluginarm is gone. ActionEngineFacade.deleterejects a nullish id (310760d, #17802) instead of resolving as if it had deleted, andActionEngineFacade.findno longer declarescontexton its envelope (#19315).EvalContext.apiis removed (9be2b59, #18736) — it was never bound.object.tenancy.organizationFieldandrowLevelSecurity[].tagsare retired (502f179, #19618;17e4f52, #20353). Where the tenant column really is that column, writetenancy: { enabled: true, tenantField: 'x' }.- A QA scenario's
requiresis checked before it runs (0bbe400, #20511). Unmetparams(an unset or empty environment variable) orservices(a discovery service the target does not declare available) skip the scenario with a reason, counted apart from passes, andrequires.plugins, which nothing ever checked, is refused:requires.plugins: ['@objectstack/service-analytics']→requires.services: ['analytics'], by the mapping the refusal prints. ATestResultconsumer reads the newstatus: a skipped result ispassed: falseand is not a failure. - Published types narrow to what their doors accept — TypeScript only; no
runtime accept set moves (
cf55914, #20448;681868c, #20369;dc07593, #20503).ApiError.code, and so every response envelope'serror.code, is anErrorCoderather thanunknown;JoinedReportBlockandReport.blocks[]carry the block shape; a ViewItem'sconfigis typed by itsviewKind; a flattened list overlay'sviewKind,type,columnsandoptionscarry their own types; andViewFilterRule['operator']isViewFilterOperator, so an alias such as'eq'failstsc— write the canonical id ('equals';VIEW_FILTER_OPERATOR_ALIASESis the map). Type a value that is still unvalidated asunknownandsafeParseit.JoinedReportBlockParsed,ViewItemParsedandViewItemWireParsedare added. - A custom
MetadataConversionwithretiredFromLoadPath: truemust carryretiredAfter, the last@objectstack/specversion that accepted the old shape (e956924, #20435);ArtifactForwardConversionVerdictgains'converted-retired-after', so an exhaustive switch over it adds that arm. - Remaining permission-model corrections. The effective permission map
behind
/auth/me/permissionsandcurrent_user.can()now agrees cell-for-cell with the server check — closing a write-path fail-open on the walledorganization_admin(e2c4e12, #20132, #20145, #20151, #20165); an object permission may not declarereadScope/writeScopebeside a super-user flag that overrides it (#17889); a blankAdminScope.businessUnitis refused (#19864);security explainanswers404 OBJECT_NOT_FOUNDfor a nonexistent object (#19209); and duplicatesys_permission_setnames answerUNIQUE_VIOLATION(#19437).
New capabilities in 17.5.0
Rules that read related records. A script / cross_field validation rule
can read one hop through a lookup, master_detail, user or tree field —
condition: "record.account.type == 'partner' && record.amount > 10000"
(1f05ea4, #19728). The engine loads only the columns the predicate names, in
one batched read per reference field per write, and nothing when no rule
traverses. A second hop, and comparing the id while traversing, are refused
with a prescription; field-level predicates and RLS stay non-traversing.
Permission questions inside CEL. current_user.can('crm_lead', 'edit')
answers from the caller's effective object permissions (627382b, #18781), and
the server now answers it in select-option visibleWhen, formula fields and
CEL defaultValue (0318faf, #20079, #20138) — a can-gated option is refused
invalid_option for a subject without the verb, where it used to be admitted
for everyone. plugin-security gains canReadObject / canWriteObject and
getEffectiveObjectPermissions.
MCP. resume_run submits the fields of a flow run paused on a screen, with
the same exposure, permission and confirmation gates as run_action
(08b213e, #19985). MCP OAuth works over plain HTTP when the deployment's host
is a private or link-local IP literal (RFC 1918, RFC 4193, 169.254/16,
fe80::/10) — intranet installs and os dev on a LAN address — and logs
OAuth is served UNENCRYPTED at startup
(2aac821, #19534). Audit rows written by an MCP client acting for a human now
record performed_by / on_behalf_of in sys_audit_log.metadata
(271d6bb, #18371).
Managers and approvals. POST /api/v1/auth/admin/set-user-manager writes
sys_user.manager_id, the bulk identity import reads a manager_id column, and
a set_user_manager row action puts it in the Console's Users list (4f1a56b,
#17993, #18046, #19316) — { type: 'manager' } approval rungs had nothing to
resolve on installs without a directory sync. Approval nodes gain
onEmptyApprovers: 'fallback' with a fallbackApprovers slate (b0eb9a5,
#18525).
Flows. An end node with outcome: 'refused' ends the run as refused
with a per-record message, persisted on sys_automation_run, and a refusing
child run stops its parent on every leg (cca6991, #18109, #18706, #19158). A
refusal is not a failure: it spends no retry budget and routes no fault edge.
Flows that already declared outcome: 'refused' used to record completed.
Authoring and tooling.
objectstack dev --cert <path> --key <path>serves TLS from the dev process, so an interactive MCP client's browser sign-in can be tried locally (89a652b, #17725).os testprints suite and scenario names and selects scenarios with--tags(5a6267f, #20341).- Stacks can print their own first-run logins on the dev boot banner with
devHintanddevLogins[](24d622b, #19139). @objectstack/verifygives tests an in-process handle on the booted stack —hooks.run,validate,flows.run/flows.resume,actions.run,seed, each run as a named caller — andbootStackOncememoizes a boot per process (6058cb2, #17181).@objectstack/spec/dataexports the typed hookctx.apisurface (HookApi,HookObjectApi,HookQuery, …) (9a910c4, #19067).IObjectQLEngine.judgeFilter()checks whether awherecan run against an object without executing it (#20213), and expression refusals carry a stablecodeand typedparamsbeside the English message (862b6ce, #20352).- The Studio metadata forms offer 27 structured keys that only the Source tab
could edit: twelve field keys (
visibleWhen,readonlyWhen,requiredWhen,lookupFilters,dependsOn,inlineColumns, …), five action keys (patch,bodyExtra,errorMessage, …), nine object keys (fieldGroups,indexes,access,publicSharing,userActions, …) andpermission.adminScope(ec292cf, #20428;dc0ab6a, #20449;19e58e2, #20485;7db1332, #20405). No schema accept set moves. - A staged
$emptyfilter operator.@objectstack/spec/datadeclares what "is empty" means per field type — null or''on text-like fields, null or[]on multi-value fields, null elsewhere — withexpandEmptyOperatorandisEmptyFilterValue, and the drivers, the formula matcher and both analytics filter faces answer it (b810ddb, #20442;fb38607, #20523;2b53993, #20498). It is not yet inFILTER_OPERATORS, so the data engine's front door still refuses it withINVALID_FILTER; keep writing the view operatoris_empty, which still lowers to$null.
Metadata and packages.
- Actions can declare their bulk dispatch contract with
execution: 'perRecord' | 'aggregate', andaction-dispatch-contract-mismatchrefuses a view that wires the action the other way (23fc5d6, #17912). - A
type: 'doc'navigation item links abookordocfrom the app menu, checked bydocs/nav-target(ccccdcc, #19789); the Console renders it in a later objectui release. {record_id}scopes a record-page component's filter to the record in view (e7f69db, #20180); elsewhere it is refused at author time.defineStack({ artifactObjects })lets a package grant permissions on, and seed data into, a sibling package's objects (b8ec127, #18212).- Import mappings can target a part of an
addressfield (mailing_address.street), assembled into one value (443b2f4, #20246). Seed.localetakes effect:AppPluginpasses the app'si18n.defaultLocaleto the seed loader (de1a611, #17013). A stack that already authorslocale:on datasets now skips non-matching datasets.package-registryis an always-on capability thatobjectstack servemounts, so packages created through the API survive a restart on a stock boot (51297e9, #18694, #19983). A stock database gainssys_packages.- Record blocks
record:details,record:highlightsandrecord:related_listacceptrequiredPermissions,enforceFieldSecurityandredactFields(#19913, #19185);kanban.titleField,CalendarConfigSchema.allDayField,element:textheading variants and label-less navigation entries that inherit their target's label land alongside (#18561, #17877, #19019, #19089). ComponentPropsMapdeclaresaction:button,action:group,action:menu,action:icon,element:definition-listandelement:repeater(75b2169, #20420). The twoelement:blocks are no longer refused ascomponent-type-unknown, and the props gate now judges all six at its warning tier.
Email verification under open. A deployment on
audience.posture: 'open' can turn email verification off with
emailAndPassword.requireEmailVerification: false or
OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false, so a sign-up is signed in at once
— for a deployment with no mail transport that trusts its sign-ups
(65352b7, #20406). A false saved only through the settings console is still
refused, email_domain still forces verification on, and AuthPlugin warns
at boot that anyone can then register an address they do not control. The
boot report and the settings console's Audience help now state that rule
(87c37ae, #20434; 24b7085, #20421).
Messaging and audit. Notification fan-out skips a channel with no transport
and records it in sys_notification.suppressed_channels instead of writing
delivery rows that can only dead-letter (a2c2852, #18041, #19192), and walled
deployments record platform-admin standing changes on the audit ledger
(877dc03, #19194).
Notable fixes in 17.5.0
These are the patch-level entries an upgrading deployment is most likely to
notice. Everything else is in the per-package CHANGELOG.md files, which is
what they are for.
Restarts and boots. A self-hosted restart re-reads sys_metadata, so
objects authored at runtime — through Studio, PUT /api/v1/meta/object/… or
publish-drafts — keep serving instead of answering 404 OBJECT_NOT_FOUND
after os serve / os start restarts (fa00ebf, #20100). Normal boots no
longer print DATABASE_ERROR … no such table (04333d0). A seed
dataset declared once on an additive multi-package artifact registers once, not
twice (3fd3a4f, #19981), and seeded rows are claimed for the first admin
again once a slow background seed settles (2266438, #17872).
Remote Turso. Objects synced at boot convert reads, writes and filters —
booleans no longer read back as 1 / 0, nor json as text — and the first
boot after upgrading rewrites the cells written unconverted (1f89ba0,
#19863, #19904). Every declared index is created, retrofitting existing tables
on the next schema sync; tables such as sys_notification_delivery and
sys_job_queue were fully scanned on every poll (bdea10a, #17615).
Queues and runs. sys_job_queue claims due jobs that were stuck behind
not-yet-due higher-priority ones (8a017af, #18105). Run history persists
cancelled and timed_out instead of folding them into failed (775e5ec),
and a run's own terminal history write can no longer mark it failed
or re-run it (#17565, #17583).
Data. A cascade delete no longer orphans master_detail rows when a
reference value bypassed parse (e64ae15, #18503, #19080). A fraction-stored
percent's scale counts displayed decimals, so the widget's 12.34 →
0.1234 write is no longer refused (adbdbc5). Twenty-four system
objects title their records by a declared field instead of the raw id
(d624002, #20095, #20042, #20087), and a page saved without type is served
with the default type: 'record' (586934e, #20133). A grouped or aggregated
query honours search, so the group headers under a toolbar search count only
the searched rows (1c1b8c8, #20487).
Hosts that mount only the dispatcher. On createHonoApp, or any adapter
written on HttpDispatcher, the /meta doors now answer what RestServer
answers: ?id= and ?object= on lists, translated labels and a doc collapsed
to the request's locale, anonymous reads of a public book or doc, the
?state=draft and ?preview=draft reads, GET /meta/book/:name/tree, the
layered view on both spellings, and 400 for a type that does not exist
(95f729a, #20404; 5c7aa46, #20473; 9449512, #20505). Those doors and the
nine /packages doors scope a caller to the organization the identity step
vetted, so a member removed from an organization stops reaching its overlays,
drafts and packages for the rest of the session (5c7aa46, #20473;
45f428d, #20491), and an uninstall with no organization is refused
400 TENANT_SCOPE_REQUIRED before it removes the package from the running
registry (d1c01ff, #20514).
Metadata history. With no from, GET /meta/:type/:name/diff compares
against the nearest earlier version whose body differs, so the default diff
right after a publish shows what the publish changed instead of "no changes",
and its labels name the active row's own version while a draft is pending
(8cdbe0c, #20443; 397572e, #20518).
Email and auth settings. A template's subject and body_text render their
values verbatim instead of HTML-escaped (df3ba16, #20392): the plain-text
part of the verification, password-reset, invitation and magic-link mails
carried &callbackURL=, so the post-verification redirect fell back to
/. A refused auth setting no longer drops the other settings saved with it —
password policy, MFA, rate limits and session lifetime were not applied while
the console showed them saved (7d63088, #20429). The refusal is logged at
error, naming the key; a log alert on the old
Auth: failed to apply auth settings: warning matches
[auth] auth settings REFUSED and [auth] auth settings NOT APPLIED instead.
SQLite space. reclaimSpace(), which the lifecycle service calls after
every sweep that deletes rows, returns the whole freelist instead of one page
per call, and on a WAL database also returns the freed bytes from the -wal
sidecar without waiting on another connection (e01d347, #20425; 5b674f5,
#20463).
Security reporting. security/explain agrees with enforcement on record
verdicts and fails closed when a dependency throws (55cd8d4, #19984, #20000,
#20030); /auth/me/permissions reports a withheld export, so the Console
stops offering an Export button that would 403 (2767af8, #18984); and
filter-compile refusals stop disclosing a read scope's field or comparand to a
caller who did not write it (#20037, #20093).
Scaffolding. os init, os generate and npm create objectstack write
files the config actually loads and give generated objects the manifest
namespace prefix, so a fresh project passes os validate (805af4f, #20214,
#20329, #20363); os g now reports — and on a config it would break, removes —
what it wrote. os generate migration emits the DDL driver-sql creates
(#17208, #17230, #18392, #18014).
Translations. Studio's metadata-form panels are translated in zh-CN,
ja-JP and es-ES instead of showing their English source (#19401 and nine
follow-ups), and os i18n extract / translateFlow reach screen nodes nested
inside flow regions (#17644, #17521). The zh-CN, ja-JP and es-ES
platform-object bundles translate the labels, options and help that shipped as
byte copies of the English source — 320, 340 and 338 of them, Delegated Admin
on the Invite user dialog among them (9bf5e67, #20490; 6427e2c, #20530).
Dependencies. Floors raised to clear OSV advisories: hono ^4.13.5
(ca31ff6), and nodemailer a major, to ^10.0.2 (f572a7e, #20564), for
GHSA-6vj9-mwq6-2f5v — nodemailer's process-global DNS cache could give a
second SMTPS transport to the same host the first one's SNI and certificate
identity, sending its credentials to the wrong TLS virtual host. Every release
from 5.0.0 through 10.0.1 is affected, so the 9.x line has no fix. ⚠️ From
nodemailer 10.0.12, the version a fresh install resolves, requireTLS wins
over ignoreTLS / opportunisticTLS, and SmtpTransport sets requireTLS
whenever TLS is on and the port is not 465. A
transportOptions: { ignoreTLS: true } override on such a port, which ran a
cleartext session under nodemailer 9, now performs the STARTTLS upgrade or
fails the send; set secure: false to connect in the clear on purpose.
ip-address and undici move with the same sweep.
New in Console (Studio) — objectui pins in 17.5.0
Four pin moves carry the console half of this release:
53ded82bf7a4 → 87af769e9a3e (fbc12be, #19398),
87af769e9a3e → 62597c588072 (48c91e9, #19832),
62597c588072 → f8a9d0fb0596 (0bf85ea, #20036) and
f8a9d0fb0596 → dd3f7e1be356 (3cf6449, #20436). The per-commit lists are in
packages/console/CHANGELOG.md under ## 17.5.0, which records the upstream
objectui commit for every entry. The first move is by far the largest — 584
releasing objectui changesets across 1,156 commits — and the fourth carries 325
across 328 commits. Each of those two changesets lists 100 of its entries, so
the highlights below are drawn from those 200 and from the two smaller moves
(27 and 86 releasing changesets). The fourth move also carries 25 objectui
commits with no changeset, among them the injected-client boot fix
(objectui#10920).
⚠️ Console hosts and authors: 98 entries in the first range, 9 in the third
and 41 in the fourth are declared breaking upstream. They are objectui's own
surfaces — they matter to a host that builds on @object-ui/* packages or
authors objectui page JSON directly. None of the four bumps registers an
ADR-0087 migration: no ObjectStack authorable key moves with them.
- Record blocks enforce
requiredPermissionsfail-closed — onrecord:quick_actions,record:details,record:highlightsandrecord:related_list, read as an ADR-0066 capability set; before, every reader of the object passed. Forms no longer offer or submit a field the caller may read but not edit, nor server-owned columns. - A record id is a
stringeverywhere metadata names one (objectui#9511): an authoredrecordId: 42/resourceId: 42is no longer accepted. bodyis no longer a child-list key; authorchildren(objectui#9847), and containment is decided by the declaredchildrenslot rather thanisContainer(objectui#9910).object-grid'soperationsis the ceiling overrowActions, andoperatorsonobject-gridis refused by name (objectui#9739).- Retired or refused by name: the published
RootRedirect, the KanbanallowCollapsekey (objectui#8801), the Tremor chart adapter (objectui#8650), the calendardateField/endFieldaliases (objectui#8355),AIInsightsSchema(objectui#8800),EventHandlersSchema/UIEventHandler/EventableSchema(objectui#6910, objectui#6497),carouselfromAIRecommendationsSchema.layout(objectui#10330) and 16NamedListViewmembers (objectui#7924); and, in the fourth range,divon akind:'html'page, whose compile error namesbox, the timeline node'sevents,orientationandposition(objectui#6170), afilter-buildernested sub-group (objectui#9306),ObjectChartSchema.xAxisField/yAxisFields/aggregation, and the app node'sactionsarray — app-level actions arenavigationitems oftype: 'action'. - Grid grouping is server-side (objectui#7189): the set of groups and every
number in a group header — the count and any per-group aggregation — come
from the query, and the rows inside a group are paged by the server. A
grouped grid over a data source that declares no
queryGroupHeadersrefuses grouping instead of grouping a page of rows (objectui#10881). - Uploads submit a
sys_fileid or are refused by name. An avatar pick goes through theUploadProviderand is never stored as adata:URL, and a file or image upload that surfaces nosys_fileid is never submitted as an inline blob. - An app has one favicon and one logo spelling,
branding.faviconandbranding.logo, which now shows in the console; the Studio app wizard saves an app the platform accepts (objectui#10842, objectui#10827). - Behaviour changes a user will see. Dates and numbers format in the
session's display locale, not the machine's, and a browser that changes hands
no longer keeps the previous account's UI language; date-only values render
their own calendar day in every timezone. Currency amounts take their
decimals from the currency. An action hidden by its own
visibleis no longer run byautoTrigger. A bare field reference typed into a hook's "Run only when" box is an error in the editor. A form cannot be saved while an upload is still running. A registration started from an invitation link comes back to the invitation after email verification. A currency field indynamicmode shows the tenant's currency (objectui#10422). After the console'slucide-react1.43 upgrade an authoredicon: 'trash-2'still validates but draws no glyph — writeicon: 'trash', as the platform's own delete actions and the flow builder's Delete Record node now do (3cf6449). - New: a "Language" item on the profile page writes
sys_user.locale(objectui#7501); a recipient picker for thefieldsharing recipient (objectui#7613); Studio's publish, AI build bar and chat draft cards report the authoring gate's per-draft advisories (objectui#6965, objectui#10039); typed controls for the flowendnode'smessage, andFlowRunnerrenders arefusedrun as a close-only notice (objectui#9336, objectui#7707); per-file view and download on read-onlyfilefields (objectui#9161); a storage-capacity banner for the environment admin, from the tenant runtime's own storage verdict (objectui#10439); and "Clone to customize" as a package-provided permission set's primary action (objectui#5987).
Also shipped in 17.5.0 — not in its CHANGELOG
The publish ran from main at 0f6dcac5 (Release run 36536081716), eight
first-parent commits after the version commit 8c87d26a, so the 17.5.0
packages on npm also carry the eight commits below. The version commit did not
consume their changesets, so no 17.5.0 CHANGELOG.md entry names them; they
will be listed again in 17.6.0's CHANGELOG.md. The release-pipeline defect
that let the publish run past its version commit is tracked in #20613.
6e3aa75(#20584) — a permission-set resolution with no active organization reads only the organization-less permission sets, the rule the grant resolution already applies, so a same-named set another organization authored no longer reaches such a principal.a093ce3(#20582) — the published SDUI manifest marks the html tier's 48 intrinsic tagstier: 'html'; no page's compile verdict changes.92fe081(#20458) — breaking: the innernameon an analytics cube's measures and dimensions is retired; see the migration note below.3a89d45(#20591) —nextUtcCalendarDayandutcInstantMsread a bare day in the years 0001–0099 as written, not as 1900–1999, so adatetimefilter$lte '0050-01-01'includes that whole day.7001918(#20598) —security/explainanswers a record-grained explanation under a policy that compares two fields of no shared comparison class with enforcement'sINVALID_FILTER/400, not a record verdict.c96beb2(#20585) — anapiflow's inbound-hookconfig.secretis withheld from every served flow definition and from a package export, and a save that omits it keeps the stored secret. ⚠️ A package exported from one deployment arrives without it, so itsapiflows are refused at registration until a secret is set again.ba4648d(#20605) —sys_comment.reactionsandsys_comment.mentionsdescribe the shapes they store.0f6dcac(#20606) — provenance comments in the remainder of@objectstack/spec's source cite commits and ADRs; comments only.
Migration — the cube member name (92fe081, #20458). measures and
dimensions are records whose key is the member's name: the analytics API
publishes it as cube.key and every query names it that way. The inner name
was a required second copy that nothing read, and one that disagreed with its
key was silently ignored. It is now refused at parse — by defineCube(),
defineStack({ analyticsCubes }) and PUT /api/v1/meta/analytics_cube/:name —
and tsc types it never.
| you wrote | write instead |
|---|---|
measures: { total_amount: { name: 'total_amount', label: 'Total', type: 'sum', sql: 'amount' } } | measures: { total_amount: { label: 'Total', type: 'sum', sql: 'amount' } } |
dimensions: { status: { name: 'status', label: 'Status', type: 'string', sql: 'status' } } | dimensions: { status: { label: 'Status', type: 'string', sql: 'status' } } |
an inner name that differs from its key, such as totalAmount: { name: 'total_amount', … } | delete it, since orders.totalAmount is already the name every query uses — or, if total_amount is the name you meant, re-key the member and update every query, dashboard and report that names orders.totalAmount |
The one-line fix is to delete name from every metric and dimension;
os migrate meta --from 17 lists the source edits. A stored analytics_cube
row or a built artifact that carries the inner name is converted on read and
at the artifact door, and os migrate meta --stored --apply rewrites the rows.
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.5.0
⛔ Nobody has walked the whole of 17.4.0 → 17.5.0. Seven lines below were
run in an upgrade of HotCRM — a 17.4.0 app with a 17.4.0-created SQLite
database — on 2026-09-29, and each says what was observed. Every other line
is derived from a change's own Migration note in Breaking changes &
migration in 17.5.0 — or, for the cube
member name, in Also shipped in
17.5.0 — 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
- Walled deployments: declare every platform administrator in
OS_PLATFORM_OWNER_EMAIL(comma-separated, verified addresses) before the new version boots. Anadmin_full_accessgrant row no longer confers standing undergroup/isolated, and a rig with no declaration comes up with zero platform admins. Not exercised. - Decide about scheduled work. If the deployment depends on packaged
time-triggered flows or
defineJobcron jobs, setOS_AUTOMATION_SCHEDULED_WORK_ENABLED=true; underisolated, addorganizationto each scheduled flow's start-nodeconfigfirst, and drain suspended runs. Not exercised. - Read the scheduled-work switch with
os doctor, which prints its effective value. Exercised on HotCRM (a 17.4.0 app with a 17.4.0-created SQLite DB), 2026-09-29: it showed "Package-authored scheduled work OFF (the default)". - Run
os migrate account-issueragainst each existing database and resolve every collision it reports, then back up. Exercised on HotCRM (a 17.4.0 app with a 17.4.0-created SQLite DB), 2026-09-29: on the 17.4.0 database the pre-flight reported thesys_account.issuerdrop safe. - Give every
apiflow's start node a non-blankconfig.secret, and sign each post with it, or declare a flow that is only started explicitlytype: 'autolaunched'; anapiflow without one stops registering at boot. Not exercised.
Getting onto the release
- Move all the
@objectstack/*pins as one set and regenerate the lockfile. The full procedure is Moving the dependency pins. Move all eleven@better-auth/*members to exactly1.7.3together, and anyzodyou pin yourself to^4.6.1or higher. Not exercised. - Leave the protocol declarations on 17:
engines.protocol: '^17', and aspecVersionrange such as^17.0.0inobjectstack.manifest.json, which admits 17.5.0. The runtime still implements protocol 17 —os migrate metaprints "this runtime implements protocol 17" — and its handshake compares only the major, so^17keeps loading across every 17.x release, while a^18range is refusedOS_PROTOCOL_INCOMPATIBLE. The "protocol 18" on the refusals and onos migrate meta's steps is the migration registry's next major, under which this release records its retirements; the 17.5.0 schemas already refuse those shapes, which is whyos migrate meta --from 17runs its chain to 18. Not exercised. - Apply the
sys_accountdrop:os migrate apply --allow-destructive, thenos migrate account-issueragain, expecting zero. Not exercised. - Do not read
os migrate meta --from 17answeringNothing to migrateas completion of this list. It now defaults--toto the highest registered major and lists the protocol-18 edits, but everything under Data, Deployment and Application code below is outside its scope. Exercised on HotCRM (a 17.4.0 app with a 17.4.0-created SQLite DB), 2026-09-29: it ran and listed 41 refusals. Its output was 874 lines, 240 of them generic protocol-18 notices, so filter the output for the refusals that name your sources. - Review each
mode: 'inclusive'thatos migrate meta --from 17offers an edge-branched decision, and delete it where the branch conditions partition — applied blindly, it makesos validatereportflow-decision-inclusive-overlap. Then runos migrate meta --stored, which lists the stored decisions underdecisionModeReviewand rewrites none, and addconfig: { mode: 'inclusive' }to each one that meant every true branch. Exercised on HotCRM (a 17.4.0 app with a 17.4.0-created SQLite DB), 2026-09-29: 13 decisions were offered the key, all partitioned, so none needed it;os migrate meta --storedreported 0 rows. - Recompile every artifact built by an earlier
os compilebefore serving cubes from it — it carriespublic: falseon every cube, which now hides them. Not exercised.
Metadata and build — run os validate before you ship
- Wrap the config's default export in
defineStack(...)(each input of a composition too), moving any key spread onto a copy into the call; then fix the stack-level findings the plain export hid. Not exercised. - Fix every refused RLS predicate the way
rls-predicate-unenforceableprescribes; removecheckfromselect/deletepolicies; give ausing-only policy acheckwhere writes must land outside it; compare a field only with a field of the same comparison class, in sharing-rule conditions too. Not exercised. - Rewrite the view and page shapes: form
layout: 'inline' | 'grid'→'vertical'(pluscolumns); list-viewsortstrings → the array form; page andobject-*filterrecords → theViewFilterRulearray; a list view'stabs→ onelistViewsentry per tab; delete viewowner/hidden, list-viewtype: 'page'/pageNameand unfulfillableundoable: true. Not exercised. - Delete
page.assignedProfiles. Exercised on HotCRM (a 17.4.0 app with a 17.4.0-created SQLite DB), 2026-09-29: the key was refused on boot exactly as documented. - Rewrite the flow shapes: give every
waitnode awaitEventConfig, every decision branch anexpressionand alabel, every builtin node the keys its executor requires, everyconnector_actionnode a completeconnectorConfig, and every evaluated slot a non-blanksource; movescreen/wait/approval/endout of region bodies. Not exercised. - Give every
lookupscreen field areference. Exercised on HotCRM (a 17.4.0 app with a 17.4.0-created SQLite DB), 2026-09-29: applied as documented. - Move each dashboard widget's
chartConfigstructure onto the widget:chartConfig.typeonto the widget,xAxis.fieldtodimensions, andyAxis[].field/seriestovalues. Exercised on HotCRM (a 17.4.0 app with a 17.4.0-created SQLite DB), 2026-09-29: 34 sites, applied as documented. - Rewrite the other analytics shapes: delete cube-join
sql/relationship/on; sub-day granularities →day; measure aggregates the field type accepts; one measure per metric-family widget; deletepublic: falsefrom every cube meant to be queried. Not exercised. - Delete the retired keys:
currencyConfig.precision,scaleon currency fields,connector.connectionTimeoutMs,connector.health/status/webhooks,object.tenancy.organizationField,rowLevelSecurity[].tags, chartaria,ariaon actions,object-kanban.quickAdd,settingsin per-app translation bundles,requires: ['reports'],api.documentation.version, the innernameon cube measures and dimensions, and the seven dead cron positions. Rename the eighteen kernel and system duration keys, and a QA scenario'srequires.plugins→requires.services. Not exercised. - Fix
manifest.idto reverse-domain notation with no underscores — a republish, not an edit. Not exercised. - Expect new
error-severity findings you did not have — orphaned locale keys, unresolvedreferencetargets, per-package findings on ADR-0130 projects, measure aggregates, FLS keys naming no field, misspelt field-name lists and index columns, and view containers whosenamedisagrees with their object. A deployment gating onos lintturns red before the runtime does. Not exercised.
Data and database
- Re-check dashboard numbers built on
dateRangepresets — the presets now filter on every backend. Not exercised. - Know what new numeric columns look like:
numeric(65,30)for decimals,integerforrating, and noNOT NULLfromrequired: truealone. No existing column is retyped. Not exercised. - Repair blank strings stored in non-text columns with the changeset's
SQLite query if your data has them; a cleared field now stores
null. Not exercised. - Remote Turso: plan schema work against a local
file:copy —os migrate plan/applyexit non-zero against the remote transport — and expect the first boot to rewrite unconverted cells and build missing indexes. Respell a bare-pathurlasfile:…, dropsyncUrl/syncfrom a forced-remote config, and run schema sync where a read over a missing table or column now refuses. Not exercised. - Expect
os migrate planto list each declared index that can never be built asunbuildable_index; it clears only when the metadata names stored fields or drops the index. Not exercised.
Deployment and configuration
- Add
keyPrefixto every S3 storage adapter —nullkeeps today's keys. Not exercised. - Re-read
sys_notification_deliveryretention: failed deliveries go after 7 days, andretention_overrides.maxAgenow moves only that window. Not exercised. - Grant object read to any principal that uses analytics on an object it
could not already read through
/data. Not exercised. - Machine-to-machine MCP clients move to API keys; OAuth
client_credentialstokens are refused. Not exercised. - Organization admins lose read on the SCIM projection tables; grant it in your own permission set if someone needs it. Not exercised.
- A principal with no active organization keeps only its global grants: have it act in the organization, or grant a permission set globally when it is meant to apply everywhere. Not exercised.
Application code, hooks and flows
- Handle the anonymous
401:client.auth.me()rejects withUNAUTHENTICATEDwhen nobody is signed in. Not exercised. - Pass the query envelope to
ctx.engine.find—{ where: { … } }— and re-check each handler's row counts. Not exercised. - Narrow
findOne'snullandupdate's number arm, and makeafter*hooks and custom drivers return the declared shapes. Not exercised. - Move
beforeUpdatelogic that read areadonlyfield fromctx.input.datatoctx.previous, and removereadonlyfields from ADR-0092 update whitelists. Not exercised. - Replace
registerHookonbeforeFindOne/afterFindOne/before|afterCount/before|afterAggregatewithbeforeFind/afterFindor a middleware. Not exercised. - Rewrite refused filter shapes in code — arrays in the equality slot,
arrays under
$ne, non-objectwhere, text operators on non-text columns, a number field compared with anything but a number, unknownhavingtokens — and compare aggregate results as numbers, a fractional sum with a range. Not exercised. - Write values in their declared spellings: a
dateasYYYY-MM-DD, adatetimein an ISO 8601 spelling, both on a real day in 0001–9999, and a number as a number or its plain JSON spelling; fit a declaredprecisionand aprogressfield's bounds; convert a decimal-comma file before/import. Not exercised. - Fix the TypeScript the narrowed published types refuse —
ApiError.code,ViewFilterRule['operator']aliases, a ViewItem'sconfig— and give a custom retiredMetadataConversionitsretiredAfter. Not exercised. - Call
/diff,/historyand/auditwith an authoring capability, and read a404from/layersas an absent name. Not exercised. - Move off the removed APIs:
GET /api/v1/automation, the export-job family,client.reports,@objectstack/spec/cloud, the Package API names now in@objectstack/spec/api-assembled, andCONCURRENT_LIMIT_EXCEEDED. Not exercised. - Send
enableOnInstall: trueexplicitly when a re-install must enable a package, and send install options as JSON booleans on the wrapped body. Not exercised. - MCP callers use the declared argument names and send
confirm: truefor actions that declareai.requiresConfirmation. Not exercised.