17.1.0
Release notes and upgrade checklist for 17.1.0 of the v17 line.
Highlights — 17.1.0
- Deactivating a permission set or a position now actually stops granting
access. Both objects shipped a Deactivate action whose dialog promised, in
all four locales, that access stops — and nothing read the column: a position
seeded
active: falsestill granted its permission sets, and a permission set seededactive: falsestill returnedposture: PLATFORM_ADMIN(#8613). A sharing rule reached users by a second road that never passed that seam, so a rule sharing records with a deactivated position kept sharing them (#8710). Both are closed. ⚠️ On a deployment that used the switch believing it was inert, this is a real revocation on live data — read the upgrade checklist. - Platform admin sets stop shipping a bulk-export wildcard.
admin_full_access,organization_adminand the derivedorganization_admin_no_bypasscarriedobjects['*'].allowExport = true, which made the 17.0 export axis undeniable: an application could declare an object exportable by nobody and the platform exported it anyway (#8681). Export is now granted per object, in an app's own permission set. - Partial field masking.
FieldSchemadeclaresmaskingRule— a closed preset enum (phone,id_card,bank_account,email,name) plus a{ keepHead, keepTail }escape hatch — served masked-but-recognisable (138****5678) to every non-system caller, withrequiredPermissionsas the unmask gate (#8993). sys_audit_logcan answer "who viewed this record". The ledger covered writes only; it gains areadaction, its writer, and arecord_viewslist view. Record-detail reads only, per-object opt-in, batched off the request path, and never any field values.- All three flow doors answer the same status table. A refused dispatch stops
reporting a failed run:
404not found,409FLOW_DISABLED,422FLOW_NO_START_NODE,400FLOW_FAILEDfor a run that actually ran and was rejected. The automationtriggerroutes (#9378 line), the actions door (#9446) and declaredtype: 'flow'endpoints (#9462) now read one shared definition instead of three private copies. error.codeis a closed vocabulary at every door (#9106). A thrown code outsideStandardErrorCode ∪ ERROR_CODE_LEDGERno longer reacheserror.code; it rides the new optionaldeclaredCodeinstead, so an app's own spellings survive without widening the platform vocabulary.- Console: two objectui pin moves (
665661ab0932 → 82a94170c405 → 9a3daf8d37ad) bringing dashboard component re-keying, the retirement of the structuredconfirmobject on actions,I18nLabelon metric widgets, and host-performedsubmitBehavior.urlredirects for consoles mounted at a sub-path.
What's new in 17.1.0
17.1.0 was published to the latest tag on 2026-08-20, six days after the
17.0.0 GA. It is a large minor: the version-locked train moved 69 packages,
carrying 111 minor and 203 patch changelog entries and no major. The bundled
Console advances two pins, 665661ab0932 → 82a94170c405 → 9a3daf8d37ad.
⚠️ Read this before treating the version number as a safety guarantee. A
minor adds capabilities without breaking existing metadata or code — but several
changes here landed after the 17.0.0 cut and ship as minor under the lockstep
launch-window convention while being explicitly breaking, and they say so in
their own changelog entries: the export-wildcard withdrawal (#8681), the strict
close of the top-level stack surface (#8687), the dashboard modal target
narrowing (#9013), and the list-view sort refusal (#9257). Two more —
sys_permission_set.active / sys_position.active (#8613) and the sharing-rule
recipient filter (#8710) — change who can read and write on live data with no
migration step to notice. The 17.1.0 upgrade checklist below is not optional
reading for this release.
The practical theme is honest refusals. Where 17.0.0 made the authorable surface loud, 17.1.0 makes the runtime's answers loud: a flag that promised to revoke access now revokes it, a read that failed stops being served as an empty one, a flow that never dispatched stops being reported as a run that failed, and a credential that was never meant to be readable stops being served.
New capabilities in 17.1.0
- Partial field masking (#8993).
FieldSchemadeclaresmaskingRule— the closed preset enumphone/id_card/bank_account/email/name, plus a{ keepHead, keepTail }escape hatch — and plugin-security'sFieldMaskerenforces it in the same change (ADR-0049 declare = enforce). A field declaring a rule is served masked-but-recognisable to every non-system caller, and the field'srequiredPermissions(ADR-0066 D3) is the unmask gate. Masking rides the single runtime channel, so API callers, browser users, the CSV/XLSX export route and the AI-context interceptor all see the same deterministic, length-preserving value. Masked callers cannot filter, sort, group or aggregate on the field (403, the FLS predicate-oracle guard), and a write that round-trips a masked placeholder is refused with400 VALIDATION_ERRORrather than silently overwriting the stored value. New exports:FieldMaskingRuleSchema,FieldMaskingKeepSchema,FIELD_MASKING_PRESETS,maskFieldValue,MASK_CHAR. - Record-view auditing.
sys_audit_logcovered writes only, so who viewed this customer record, and when? had no answer short of custom work. The ledger gains areadaction, its writer, and therecord_viewslist view. Scope is deliberately narrow: record-detail reads only (the read materialized one record and its predicate pinned the primary key — the shapeGET /data/:object/:idproduces; list and search reads are not audited), per-object opt-in with no global switch (new AuditPlugin({ readAudit: { objects: ['contact', 'account'] } }); an empty opt-in registers no hook, so the default posture costs a read nothing), and batched off the request path — rows persist on a later tick and keep the instant the record was viewed, not the instant its batch drained. The row records who, what and when and never field values: read auditing runs ahead of field masking, so copying values in would mint a plaintext copy of exactly what field-level security withholds. A system-elevated read (api.sudo(), formula recomputes, roll-ups) and a read with no principal both write no row. - Read-only approval visibility, per object, default OFF (#8652).
ApprovalsPluginOptions.recordReaderVisibleObjectsnames the objects on which a user who can READ a business record may also see that record's approval requests and full action history. Omitted or empty leaves visibility exactly as it is, so an existing deployment sees no change on upgrade — but on an object you list this is not a no-op: a ledger keeper or supervisor who holds full read but never appears in the approval used to get200with an empty list, and now sees the request row (including itspayloadsnapshot), every actor and decision, the action's comment text (意见正文), and decision attachments. Enable it where the approval commentary is meant to be seen; it is per object precisely so enabling it for a ledger object does not enable it elsewhere. The tier is read-only (can_act: false, and no approval action is delivered through it), introduces no new permission concept — the service reads the record as the caller, so ordinary CRUD and RLS decide — and leaves an untargeted inbox list unchanged, so a work queue does not become a browse surface. os migrate duplicates(#8928). An operator-facing, read-only inventory of the business identifiers the tenancy split already minted twice. It reports one row per duplicated value with its holders (id, organization, partition, creation timestamp), the live condition (an object still running a__global__counter beside an organization-scoped one), and askippedlist carrying the driver's own message so an unreadable target never reads as a clean one. JSON on stdout, no persistence, no new schema, and it applies nothing — no DDL, no seed. ⚠️ Run it before the #8686 backfill:organization_id = NULLis the marker that says the row came from the untenanted side, and the repair overwrites it. Reporting is all it does — renumbering an already-minted identifier stays deliberately out of scope.- Operator-initiated re-homing of stored cleartext datasource credentials
(#8155). A datasource row created before the write door closed can still hold
its credential in cleartext inside
config; the read paths were closed, but nothing removed what was already at rest.IDatasourceAdminService .migrateCredential(name)— the Setup action "Move credential to the secret store", backed byPOST /api/v1/datasources/:name/migrate-credential— writes the secret, reads it back and compares, and only then bindsexternal.credentialsRefand drops the inline key in one record write. Per datasource, initiated by an operator, never a sweep and with no batch spelling. It is idempotent, and it refuses rather than guesses: a credential embedded in a connection URL, an alias spelling no connection builder reads, turso'sencryptionKey, and a code-defined datasource are each refused with a reason, and any credential-shaped key left behind is named in the result so "migrated" never reads as "this row is now clean". - A hook refusal can mark its own message user-facing —
userMessage(#9934). The console form deliberately discards the servermessageon 403 and substitutes a generic string, which also suppressed every deliberate, localized refusal an application hook author wrote. SettinguserMessageon the thrown error at throw time marks that text as user-facing. It is a text-carrying field rather than a boolean besidemessage, so no boundary that rewraps or substitutesmessagecan promote platform prose into the marked channel, and platform/driver code never sets it.ApiErrorSchema.userMessageandEnhancedApiErrorSchema.userMessageare additive and optional; unmarked errors produce byte-identical wire bodies. The SDK attacheserr.userMessagefrom both wire dialects. ActionSchema.onSuccess— post-success navigation (#9566, #9474). One contract for both server-executing action types:onSuccess: { navigate, openIn? }, read fortype: 'api'andtype: 'script'only (a refinement refuses it onurl/modal/flow/form, where no success event exists for it to ride).navigateis a route/URL template whose interpolation scope gains${result.*}— the action's server response payload — which is what makes "server clones a record, jump to the new record" declarable:navigate: '/apps/crm/tasks/${result.id}'.openInis the closed enum'self' | 'newTab', defaulting'self'. The existing handler-return convention ({ redirectUrl, openIn? }) keeps its 17.0.0 semantics, so no shipped handler silently flips behaviour.- Localized email templates reach the inbox and flow
notifynodes.IEmailServicegains a render-onlyrenderTemplate({ template, locale, data, timezone }) → { subject, html, text }, and the inbox channel consumes it, so localizedsys_email_templatecontent now reachessys_inbox_message(#9225). Flownotifynodes can reference a template for localized delivery —template+templateDataonNotifyNodeConfig, resolved by(name, locale)at delivery time (#9205). The locale is one value for the whole notification, not one per recipient:payload.localeif the producer set one, else the deployment default (II18nService.getDefaultLocale()) — the platform has no per-user locale, so recipients whose personal languages differ all receive the same row (a per-user locale is deferred by the 2026-08-13 ruling). mapjoins the per-visualization blocks onListViewSchema.ListMapConfigSchemais the eighth block alongside kanban / calendar / gantt / gallery / timeline / chart / tree, declaring the map renderer's documented read surface:latitudeField,longitudeField,locationField,titleField,descriptionField,zoom(1–20) andcenter. All keys are optional and none carries a default — with no camera declared the renderer fits the camera to the queried records. BecauseListViewSchemais strict, atype: 'map'view could not declare its field mapping at all before this, so any marker title field other than the renderer defaultnamewas unreachable.icontainsjoins the view and infix filter vocabularies (#8934). The operator has been executable on every driver since #5702/#6520 but was authorable from only one of the three filter dialects.VIEW_FILTER_OPERATORSandAST_OPERATOR_MAPgain the canonical spelling. The boundary is preserved deliberately:icontains(LIKE-escaped substring — a comparand%is a literal) andilike(raw LIKE pattern) are not aliases of each other, and there is nonot_icontains, because the authoring vocabularies mirror the executed set rather than widening it.- Field-level
relatedListFilter(#8704). The field-level related-list family (relatedList/relatedListTitle/relatedListColumns) gains a fourth member, closing the gap where the only way to filter an auto-derived related list was to abandon the auto-derived record page for a hand-writtenrecord:related_listpage. It carries the canonical Query-DSLFilterCondition— no new dialect — and is AND-composed with the parent-relationship condition as an authored constraint, with the tab badge count honouring the same composed filter so counts match visible rows. ⚠️ The consumption half (objectui#4664) had not landed at this pin: the key is ledgeredplannedand carries an author warning until it does. - Stamp-only
tenancy.organizationField(#8778). The platform could say what an object is walled by (tenancy.tenantField) but not which column says who a row is about — which for credential tables deliberately differ.TenancyConfigSchemaaccepts an optionalorganizationFieldthat is read-neutral and stamp-only: the audit writer consults it, andapplyTenantScope,injectTenantOnInsert,computeTenantLayer0FilterandresolveInjectedSystemColumnsare measured blind to it, pinned by tests. Declaring it never walls an object and never hides rows.sys_api_keynow declares it, so revoking a key from a different active organization lands the audit row behind the wall of the key's organization. - API keys are minted against the minter's active organization (#8287). Under
OS_TENANCY_POSTURE=isolateda minted key could read nothing at all:sys_api_keycarried no organization column, so key authentication established a user but no active organization, and every organization-scoped read answered200withtotal 0while the console went on offering minting.sys_api_keynow declaresactive_organization_id;POST /api/v1/keysinherits the caller's active organization (there is deliberately no org parameter and no cross-org key) and re-checkssys_membermembership at mint time. An ex-member's key now fails closed at verify time rather than degrading to a user-only principal. ⚠️ Pre-existing org-less keys are never backfilled — they keep working undersingleandgroup, and are refused underisolated, where they are provably dead today. storagebecomes the canonicalCoreServiceNameslot (#9683).file-storagewas the only member whose spelling diverged from its documented accessor (services.storage).storageis now canonical;file-storagestays an accepted, deprecated v17 alias —@objectstack/service-storageregisters the same instance under both names, and discovery reports the canonical key and mirrors the row verbatim under the alias for its v17 lifetime. A custom v17 provider for this slot should register both names.- Smaller authoring additions.
Field.timecloses the temporal-builder gap —'time'was a fully declaredFieldTypewith no builder, splitting the three temporal types two-and-one (#8656).placeholderbecomes a declaredFieldSchemakey, ending the preview-renders/save-422s trap where four shipped objectui render surfaces applied the key andPUT /api/v1/meta/object/:namerefused it (#9019).security.explain()accepts therecordIdsbatch spelling (#8480).IHttpServergains an optionalafterResponseresponse-observing hook.GanttConfigSchemadeclaresviewMode.
Security corrections in 17.1.0
The largest cluster in this release, and the reason its minor version number is not a safety guarantee.
sys_permission_set.activeandsys_position.activenow actually stop granting access (#8613). Both objects ship a Deactivate action whose confirmation dialog promises, in all four locales, that access stops. Nothing read the column: measured on the real resolver, a position seededactive: falsestill granted its permission sets, and a permission set seededactive: falsestill returnedposture: PLATFORM_ADMINwith its system permissions. Deactivation moved a badge in Setup and nothing else — while the admin who had just revoked a compromised or over-broad grant was told the opposite.resolveAuthzContext/resolveUserAuthzGrantsnow drop a deactivated row before any derivation, so a deactivated position contributes no grants and its name leavespositions, and a deactivated permission set contributes no name, nosystem_permissions, notab_permissionsand noPLATFORM_ADMINposture (the flag is applied before the posture is derived). Both tables were already read at that seam, so this costs zero new hot-path queries. A row whoseactivecolumn is absent or NULL is unaffected — the predicate is "explicitly deactivated", never "explicitly active". Enforcing the flag also opened a one-click, installation-wide lockout (deactivatingadmin_full_accessun-makes every platform admin at once, and re-activating requires the permission the click just took away), so the last-administrator guard now judges that write like the delete and rename spellings it already refused.- A deactivated
sys_positionstops conferring sharing-rule record shares (#8710). #8613 closed the authorization derivation seam; a sharing rule reaches users by a second road that never passes it, so a rule sharing records withcfokept sharing them aftercfowas deactivated. Maintainer ruling, 2026-08-15, verbatim: "Access-conferring paths filter deactivated positions; addressing paths do not." A rule whose recipient is an explicitly deactivated position now expands to nobody, and the shares it had already materialised are revoked on the next reconcile. Deliberately unchanged, per the same ruling: approval routing keeps reading the raw directory (filtering there is fail-open — an approval step routing to nobody), and write gates and blast-radius reads stay unfiltered, because dropping a deactivated row there would make a refused binding permitted — access widening. - The shipped admin permission sets no longer grant export on the
*wildcard (#8681). Measured on 17.0.0 GA across 40 export probes, 5 principals and 8 objects with real bearer tokens: an org owner exportedcrm_quote,crm_campaignandcrm_taskwith200and full data, while no app permission set granted export on any of them and the app had no way to say no — the wildcard lives in code-package metadata, so editing it answers403 [not_overridable]. This was never a gate defect; the same run proves the gate exact for every other principal. READ is untouched — this narrows bulk egress only, andallowExporton a'*'entry remains a supported authoring shape in an app's own sets. - Credentials and secrets stop being served. The metadata read path no longer
serves stored cleartext credentials (#8154), and
GET /api/v1/meta/:type/:name/diffstops serving stored credential values.sys_account's OAuth access/refresh/id tokens and itspassword/previous_password_hashesstop serializing on the data API (internal: true). Custom email headers stop being served through the generic data-API read ofsys_email(#8149). - Doors that answered anonymously now require a principal. The
external-datasource federation HTTP family requires an authenticated caller on
every route (#9686); the datasource-admin family requires authentication
(#9391) and then the
manage_platform_settingscapability;POST /meta/:type/:name/publishand.../rollbackrequiremanage_metadata(#8919); the four mutatinginstall-localroutes requiremanage_metadataand drop thex-user-idheader fallback, and theinstall-locallisting requires an authenticated principal; and the four/admin/sso/*bridges run the ADR-0068 platform-admin gate before delegating into better-auth. The REST and endpoint-route 401 anonymous-deny bodies now carrycode: "UNAUTHENTICATED"alongside the existingerror/messagekeys (#9487, #9823). - Audit attribution is the authenticated identity. On all five
/metawrite doors the recorded actor is now the authenticated identity and theX-Actorheader is ignored. Audit rows are stamped from the record's own organization rather than the actor's active one (#8707), REST/metawrite doors carry the caller's organization so rows are no longer stamped environment-wide, and the metadata audit read is scoped to the caller's organization (#8747). - Caller values stop reaching the server log. A policy-authored
INVALID_FILTERrefusal withholds the target field (#8197); four more diagnostic families stop logging the caller's value, measured off live MySQL 8.0 and PostgreSQL 16; a caller value containing" - "no longer eats the diagnostic's template head; MySQL duplicate-entry reports keep the value out; andpublish-draftsno longer discloses driver or subscriber text onunhideError/rebindError(#8516). - Two ADR-0055
controlled_by_parentcorrections. The master-editability check consults the same app-authored write widener the by-id path does, and detail writes compose the master's ownership floor the same way a direct write does (#8865). ⚠️ The first of these is flagged in its own entry as widening who may write rows that are refused today. An insert that omits a required master-detail parent now answers400 VALIDATION_FAILEDwithfields[]rather than a security-shaped refusal, and security explain reports partial masking as a third state instead of calling gated fields hidden.
Behavior changes & fixes in 17.1.0
The flow doors answer real HTTP statuses
- The automation
triggerroutes now answer409FLOW_DISABLEDfor a disabled flow and422FLOW_NO_START_NODEfor a definition with no start node, instead of200wrapping an inner{success: false}.AutomationResult .codegained the two members deliberately — rather than the transport guessing from message text — and they carry nostatus, which is what lets a transport tell a never-dispatched exit from a run that dispatched and failed. Two statuses and not one because a disabled flow is reversible operational state (409) while a definition with no start node cannot be executed as stored and no retry helps (422). POST /api/v1/actions/:object/:actionanswers that same table instead of one blanket400 FLOW_FAILED(#9446), and declaredtype: 'flow'endpoints join it (#9462) — that door used to answer200for every outcome with the raw engine result indata, so a client branching on HTTP status read every failure as a success on the surface an app publishes as its own public API. AnoutputMappingis no longer applied to a failure, closing a hole where a refused dispatch could be presented as data. All three doors now read one definition (packages/runtime/src/flow-dispatch-status.ts).- The resume route closed its envelope.
POST /api/v1/automation/:name/runs/:runId/resumerefuses a body carrying an unknown top-level key — the accepted set is exactlyinputs,variables,output,branchLabel— with400 VALIDATION_FAILEDnaming the offending keys. A body like{"nodeId":"ask","values":{…}}used to answer200 success:truewith the screen submission treated as empty: the run completed and the submitted value never reached the flow. A sibling change refuses an accepted key carrying a value of the wrong type. - A flow ACTION that ran and failed now carries the flow author's
errorMessageand the runsummaryinerror.details, and a triggered run carries the author'ssuccessMessage/errorMessagethroughexecute()and both retry exits.
A failed read stops reading as an empty one
A recurring class this release closes in several packages at once: a read that FAILED was indistinguishable from a read that legitimately found nothing.
- Four metadata-protocol seams (#8896).
SeedLoaderService.loadExistingRecords()returned an emptyMap— which is not a cache but the write decision, so the upsert pre-load turned every update into an INSERT andbulkWrite's retry recheck was silently disarmed.searchAll()reportedtotalObjects/totalHits/truncatedas though a partial scan had been complete.findReferencesToMeta()dropped whole source types from the list that answers "what would break if I delete this", so a short list read as "nothing depends on it".publishPackageDrafts()pushed a fabricated revert-plan entry whoseexistedBefore: falsemeans "revert = soft-remove", so reverting that commit would DELETE an artifact whose previous version was supposed to be restored. None of the fourcatches is removed — each is discriminated by error type through the sharedisMissingTableErrorpredicate, so an unprovisioned table stays benign and everything else surfaces. - Siblings across the tree:
getMetaDiagnosticsstops publishing an unreadable store as "0 problems" (#8855);auditMetaItemstops reporting a failed audit read as an empty trail, and a missingauditMetaItem/findReferencesToMetacapability is refused rather than answered as "no audit trail" / "nothing depends on this" (#9426, #9326);diffMetaItemstops serving a history outage as an empty diff (#8868, #8833); a failedsys_organizationprobe stops reading as "this install has no organizations" (#9261); the roll-up summary index's failed read is never cached as an empty index (#9154); a cascade-delete dependents probe that FAILS no longer skips the referential guard;ObjectQLPlugin's three registry reads stop inventing an empty registry (one silently skipped schema sync); a failed governance row-count probe stops being indistinguishable from a quiet object; andGET /api/v1/meta/:typerefuses a type name that names nothing instead of serving it as an empty collection (#9488).
Author-time gates reach the runtime publish door
Rules that only os build / os validate ran now also judge a runtime write, so
Studio and the metadata API cannot land what the CLI refuses.
- Dashboard writes are judged by
validateWidgetBindings(#7529) — a widget bound to a dataset that resolves to nothing was previously a200on the publish door. All six of the rule's error-tier findings gate a publish as one reference-integrity class. - The five gating object rules cross the publish gate, so an
objectwrite through the metadata API is judged by the same completeness, managed-apiMethodsand autonumber rulesos buildruns. - The publish gate judges a package write against that package's own closure
(#9612) — it used to hand every rule the tenant's entire
objectscollection, asking a question nobody wanted answered. The unit is now the package plus the transitive closure of its declaredmanifest.dependencies, plus platform objects unconditionally, plus rows carrying no package provenance. A write that names no package narrows nothing and is judged exactly as before — an unresolvable package buys a write more validation input, never less. - The publish door reports the runtime authoring gate's advisory findings
(#9176), and
publishPackageDrafts(Studio's "publish whole app") reports them too; the batch publish response gained a spec contract (#9406). - New authoring-time refusals. A dashboard header
modalaction's target resolves against declared PAGES only (#9013) —os validatepreviously blessed exactly the buttons the runtime refuses. A sharing rule anchored where sharing has nothing to widen (apubliceffective sharing model, or a master-detail detail) is an error, with separate ids because the repairs differ (#9698). A list-viewsortnaming a formula field, or no field at all, is refused (#9257). A bare date-range preset name in an ordering filter comparand is refused at publish (#8793). Three write-surface rules ask provenance, not just membership, before exempting a system column (#8663), and the same provenance question is asked at a fifth blanket-SYSTEM_FIELDSread site,searchableFields(#8404).
The authorable surface closes further
- Unknown top-level stack keys are refused (#8687) —
ObjectStackDefinitionSchemawas the last strip-mode surface of the #4001 campaign. Measured on 17.0.0 GA, three injected bogus top-level keys added zero warnings toos validateand exited 0, even under--strict. The failure population is a typo or stale key (flowforflows,approvalProcessesafter its removal) shipping an artifact with a whole metadata family absent at runtime. Near misses carry a did-you-mean, and curated prescriptions answer the known retirements (storage,approvals,workflows,portals,onDisable).onEnableis now declared rather than undeclared-but-honoured. - Undeclared keys are refused on the analytics authoring surface (#4001 batch
D), on object
indexes[]entries, onField.inlineColumns/Field.relatedListColumns(#9227), and onrecord:alert/record:quick_actions/record:history/record:discussion(#8744) andrecord:reference_rail(#8691) inComponentPropsMap. Malformed fieldscale/precisiondeclarations are refused at authoring time (#8321), and the closed vocabulary of expression-bindable text keys is declared (#9599). - ADR-0049 retirements. The
element:filterelement retires at element grain (#9220); the inerttargetVariablekey leaveselement:text_inputandelement:record_picker(#9198);MetadataPluginConfigloses the inertadditionalTypes(#8586);ApiKeySchemaretires — the identity module stops publishing a second, fictional declaration ofsys_api_key(#8715);sys_user_permission_setretires thedelegated_fromcolumn (#9730); andBATCH_PARTIAL_FAILURE,BATCH_COMPLETE_FAILUREandTRANSACTION_FAILEDleaveStandardErrorCode(ADR-0112 amendment, 2026-08-18).
Datasource credentials reach the driver
- A bound
external.credentialsRefnow reaches the mongo client on a URL branch, the mysql client on the DSN branch (#8696), and the postgres server on the DSN branch, not just the knex config (#8873). A mysql datasource that declares TLS now gets it, on both branches and in the spellingmysql2can read (#8874). - Publish-time refusals close the authoring half: a credential in the mongo
options passthrough (
config.options.auth.password, #9040); credential-bearing URL query parameters (?authToken=/?password=, #8337); a postgresconfig.urlthatpgitself cannot parse (#9091);${…}placeholder syntax in memorypersistence.path/persistence.key(#8495); and the contradictory pairs "credentialsRefbound + a composed mongo config naming nousername" (#9147) and "credentialsRefbound + a mongoconfig.urlnaming no user" (#9041).
Drivers, query engine and analytics
- One unresolvable WHERE column, one answer.
find()andcount()both refuse withINVALID_FILTER/ 400 naming the column, and MySQL joins the predicate with the full refusal envelope and recoveries (#8926). - MySQL upserts stop merging onto rows the caller never identified. An upsert
whose
conflictKeysno PRIMARY KEY or UNIQUE index backs is refused (calls that previously "resolved" now fail loudly), an upsert whose named conflict target another UNIQUE key can absorb is refused (#8755), a merge that would rewrite the row's primary key is refused and rolled back (#8622, #8807). - MySQL metadata locks stop hanging. A blocked
os migraterefuses in 120s instead of hanging effectively forever (#9354), and boot schema-sync's widening ALTER bounds its lock wait too — a blocked boot warns and carries on, and an un-run widening ALTER is reported aterrornaming the fix. - Cascade delete and multi-value references.
cascadeDeleteRelationsprobes amultiple: truereference field with a spelling its storage can answer; the required-FK escalation on amultiple: truelookup is judged per row, so a parent delete is refused only over the rows that actually hold it;deleteBehavior: 'set_null'on amultiple: truereference removes the deleted member from the stored array instead of nulling the whole field; and the delete-cascade path's registry reads propagate instead of answering "no relations" (#9002). - Undeclared fields are refused at the schema on insert (#8682) and on update
before
beforeUpdatehooks run (#8738).[]no longer satisfiesrequiredon a multi-value field (#9476). A TRUEreadonlyWhenno longer strips hook-derived values — the conditional strip judges only API-boundary callers (#9107). A temporal filter comparand the platform cannot interpret is refused at the engine door instead of answering200with zero rows, and a scalarin/nincomparand no longer reaches a driver (#9228). - A dotted filter key whose head is a relation, a formula or a plain scalar is
refused at both doors (#8371), and unscoped
multi: truewrite refusals are restored through the wired engine onsys_attachment(#9719) andsys_comment.
CLI & developer experience
objectstack initscaffolds now compile (#9666).init … --installreported✓ Scaffold validatedand the next documented step,npm run dev, failed to compile: the CLI's own shipped template was refused by the CLI's own shipped rule set, because theappandplugintemplates declared nosharingModel(OWD). Both templates now declaresharingModel: 'private'— the rule's recommended default and the ADR-0090 D1 baseline — andinit's self-test now runs the author-time rule registry over the generated project instead of only checking that the config loaded. The rule set is thebuildone, the same setos devreaches, so this is a shift-left rather than a stricter bar.- The ready banner names what actually booted (#8978). On an
OS_ARTIFACT_URLboot theobjectstack.config.tsin cwd is deliberately never executed, but the banner'sConfig:row still printed it; on the plain artifact-fallback path it named a config file that does not exist on disk at all.servenow reports the resolved artifact's already-redacteddisplaystring in anArtifact: … (OS_ARTIFACT_URL)row, omits the row where no safely-redacted value is in hand, and reports the authored config exactly as before on an ordinary config boot. os start/os devstop writingOS_ARTIFACT_PATHinto the childserveenvironment (#8985). Both supervisors handed the resolved path down through the same variable an operator sets, before the downstreamobjectstack.config.tsis evaluated — so a config could not answer "did a human ask for this, or did the CLI put it here?". The path now travels onOS_INTERNAL_ARTIFACT_PATH, a channel the CLI owns both ends of, restoring the property that the presence ofOS_ARTIFACT_PATHin a config's environment means an operator set it. Nothing about resolution changed:--artifact > OS_ARTIFACT_URL > OS_INTERNAL_ARTIFACT_PATH > OS_ARTIFACT_PATH > <cwd>/dist/objectstack.json.os inittemplate descriptions stop advertising metadata kinds they never emit (#9737); scaffolded projects stop describing themselves as the blank template (#9263); the scaffolded Dockerfile pins the runtime image to the CLI that builds the artifact instead oflatestunder a comment saying to pin (#9017); the blank template'sspecVersionstops shipping eleven majors stale (#9264); and the five remote content templates retire from the scaffolder's catalog.os migrate meta --from Ncan finally open the retired-key sources it exists to rewrite (#9418);os meta resyncexplains a nonzero skip count instead of leaving it to look like a no-op (#9184);servewarns when the declared replica count exceeds the licensed node cap — ⚠️ deliberately advisory wording, because nothing is actually refused yet (#8504); andobjectstack serveregistersObservabilityServicePlugin, soobservability:metricsresolves for every consumer following the canonical resolution chain.
Observability, packaging and published docs
http_requests_totalandhttp_request_duration_msare emitted from the transport seam, so every inbound mount is counted (#9650)./discoverydescribes the request's environment rather than the control plane (#9292), and itsmcpadvertisement follows the request's environment too. A zero oncache_*is documented as meaning "no configured consumer", not "no cache activity" (#9954).- The plugin-mounted Hono error paths answer the declared envelope — six refusal bodies stop speaking the pre-#3675 dialect (#9364) — and the hono adapter's and the dispatcher's two discovery bodies join the response envelope (#9436, #9813).
- A broad published-README accuracy sweep: five service READMEs, four more
READMEs,
driver-sql,mcpandplugin-auditstop documenting symbols, APIs and call sites that do not exist (#9532, #9544, #9867, #9579), and published READMEs link to the docs site in the one form that works on npm, on GitHub and on the docs site (#9632). The better-auth family moves off the1.7.0-rc.2prerelease onto stable^1.7.1.
New in Console (Studio) — objectui pin 665661ab0932 → 9a3daf8d37ad
Two pin moves in this release (665661ab0932 → 82a94170c405, then
82a94170c405 → 9a3daf8d37ad). Notable declared changes:
- Breaking (published API).
app-shellis no longer a component key;NavigationRendererno longer acceptsresolveGroupLabel/resolveItemLabel(app-navigation localization is owned by the server-side/metaboundary);<PageHeader>retires the legacydescriptionprop, leavingsubtitleas the only spelling;dashboardComponentsis re-keyed from 11 PascalCase component class names to the 8 schematypekeys the package actually registers; and the structuredconfirmobject on actions is retired in favour ofconfirmText(objectui#4314, ADR-0049). - Modal targets name pages. Both of objectui's copies of the prefix
convention retire — a
type: 'modal'action's stringtarget, and a dashboard headermodalaction'starget, each name a PAGE only. This is the renderer half of theos validatenarrowing above; the two repos now agree. - Declared surfaces catch up to the spec. The five
@objectstack/spec17.0.0 keys the renderers already honoured are published so authors can discover them;element:text.contentandelement:button.labeldeclare the inline translation map they already accept;ComponentInput.typecan declare a union;ObjectMetricWidgetPropsspeaksI18nLabelonlabel,trend.label,descriptionandtitle;ObjectMapSchemadeclares what ObjectMap reads, with themapblock outranking the flat spelling;AuthInvitation.statusbecomes the closed four-member union it always documented. - Keys that declared nothing are settled.
AccordionItemandToggleGroupItemeach retireiconand wiredisabled;VectorFieldMetadata .indexed/.distance_metricandBaseFieldMetadata.indexedare removed along with the field designer'sIndexedtoggle, because the ObjectStack spec has no field-level index flag; and the retiredstriped/bordered/virtualScrolllist-view surface is removed. - Rendering and data fixes.
FormSchema.onChange— a declared callback the form renderer never invoked — is called (objectui#4259); an option colour authored as an explicit hex renders as authored instead of being quantized to nine palette families (objectui#5141);ObjectGrid,bridgeListViewandObjectDataTableread the declared column spelling and resolve column identity before handing columns to the table; form-field type resolution no longer falls back toui-namespace SDUI node renderers; a top-levelconditionalFormattingno longer reaches the kanban view;DatasetReportRendererstops readingfilteras an alias forruntimeFilter; a clicked cartesian mark names its own series and the drill title reads its label; and a null second-dimension group is drawn rather than carrying its measure invisibly. - Navigation and shell. A form's ruled
submitBehavior.urlredirect can be performed by the HOST, so a destination stays inside a console mounted at a sub-path (objectui#4989); the declared navrunActionslot is consumed and the private?runAction=string convention retires; and the current organization shows in the top bar for users with exactly one membership.
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.1.0
⚠️ Despite the minor version number, four of these are behaviour changes on live data or on a published wire contract. Work through them before upgrading.
-
Audit your deactivated permission sets and positions FIRST (#8613, #8710). Any
sys_permission_setorsys_positionrow currently carryingactive: falsestops granting the moment this lands, and a deactivated position named as a sharing-rule recipient has itssys_record_sharerows revoked on the next evaluation. That is the correct direction — it is what the Deactivate dialog always said — but on an installation that used the switch believing it was inert it is a real revocation, with no migration step to notice. List them and re-activate any that are still meant to grant:GET /api/v1/data/sys_permission_set?filters=[["active","=",false]] GET /api/v1/data/sys_position?filters=[["active","=",false]] GET /api/v1/data/sys_sharing_rule?filters=[["recipient_type","=","position"]]Rows whose
activecolumn is absent or NULL are unaffected. -
Grant
allowExportexplicitly wherever admin export is intended (#8681). The platform admin sets no longer carry the'*'export wildcard, and nothing fails at parse time — a deployment that upgrades without editing anything is valid metadata whose administrators have quietly lost export on every object no app set names. There is deliberately no automatic replacement. Author it in your app's permission set (platform sets are not overridable):{ name: 'system_admin', objects: { crm_account: { allowRead: true, allowExport: true }, // export intended crm_quote: { allowRead: true }, // export withheld }, }Verify behaviourally: sign in as an org owner and call
GET /api/v1/data/<object>/export, expecting200where export is intended and403 EXPORT_NOT_PERMITTEDwhere it is not. -
Re-run
os build/os validateafter upgrading. Several new refusals can fail a stack that built clean on 17.0.0: an unknown top-level stack key (#8687 — check for a typo or a stale key such asflowforflows, which until now shipped an artifact with that whole family absent at runtime); a dashboard headermodalaction whose target names an action, a bare object or a<verb>_<object>form rather than a declared page (#9013 — those buttons already dispatch to a refusal at runtime; there is deliberately no automatic rewrite, andos migrate metasurfaces it as a structured TODO); a sharing rule anchored on an object whose effective sharing model ispublicor which is a master-detail detail (#9698); a list-viewsortnaming a formula field or no field at all (#9257); a bare date-range preset in an ordering filter comparand (#8793); and undeclared keys on the analytics surface, on objectindexes[], onField.inlineColumns/Field.relatedListColumnsand on five morerecord:*component prop maps. -
Studio and metadata-API writes are now judged by the same rules. If you publish through
PUT /api/v1/meta/…or Studio rather than the CLI, expect writes that previously returned200to be refused — dashboard widget bindings (#7529) and the five gating object rules now run at the publish door. -
Flow callers: branch on the HTTP status, not on an inner
successflag. The automationtriggerroutes,POST /api/v1/actions/:object/:actionand declaredtype: 'flow'endpoints all answer404/409 FLOW_DISABLED/422 FLOW_NO_START_NODE/400 FLOW_FAILED. A caller that treated400as "the run failed", or that read a declared endpoint's result out of a200body, needs updating — a declared endpoint's failures are now 4xx with the platform error envelope and no innerdata.successto read.outputMappingis no longer applied to a failure. If you call the resume route, send onlyinputs,variables,outputandbranchLabel: any other top-level key is now a400. -
Apps that throw their own
error.codeshould readdeclaredCode(#9106). A thrown code outsideStandardErrorCode ∪ ERROR_CODE_LEDGERno longer reacheserror.code; it arrives on the new optionaldeclaredCodeinstead. One-line fix: branch onerror.declaredCodefor app-specific spellings and onerror.codefor platform conditions. Platform producers are unaffected. -
Retired surfaces.
sys_user_permission_set.delegated_fromis gone (#9730);element:filter(#9220),targetVariableonelement:text_input/element:record_picker(#9198),MetadataPluginConfig.additionalTypes(#8586) andApiKeySchema(#8715) are retired; andBATCH_PARTIAL_FAILURE,BATCH_COMPLETE_FAILUREandTRANSACTION_FAILEDleaveStandardErrorCode. Console hosts:app-shellis no longer a component key,<PageHeader>'sdescriptionprop is gone in favour ofsubtitle, the structuredconfirmobject on actions is retired in favour ofconfirmText, anddashboardComponentsis re-keyed to the eight schematypekeys. -
file-storage→storage(#9683). The canonicalCoreServiceNameslot is nowstorage.file-storagekeeps working as a deprecated alias for the v17 line and retires at the next major; a custom v17 provider for this slot should register both names. -
API keys under
isolatedtenancy (#8287). Keys minted before this release carry no organization and are refused underOS_TENANCY_POSTURE=isolated, where they already read nothing. They are deliberately not backfilled — re-mint them. Keys keep working undersingleandgroup. -
If a config read
process.env.OS_ARTIFACT_PATHexpecting the CLI to have populated it (#8985), set the variable yourself or read the artifact from the config's own inputs.OS_ARTIFACT_PATHremains a fully supported operator knob on the rung it has always occupied;os start/os devsimply no longer manufacture it on your behalf. -
Automations: a triggered run now carries the flow author's
successMessage/errorMessage, and a flow ACTION that ran and failed carrieserrorMessageand the runsummaryinerror.details— review anything that parsed the previous generic text.