17.4.0
Release notes and upgrade checklist for 17.4.0 of the v17 line.
Highlights — 17.4.0
- Every duration key carries its unit in its own name, and a gate keeps it
that way (
e9fcd6b, #14478, ruling B). A duration-shaped number may no longer hide its unit in prose:hook.timeout/job.timeout/DriverOptions.timeoutbecometimeoutMs,dashboard.refreshIntervalbecomesrefreshIntervalSeconds, the tenant session pair becomesidleTimeoutSeconds/sessionTimeoutSeconds, and theapi/,kernel/,system/,data/,ui/,ai/andintegration/families move with them (#15676–#15682, #14519).check:duration-unit-keysgrandfathers nothing. Every rename is a parse-time refusal naming the key — there are no aliases, and the migration prescriptions are registered under protocol major 18. - A static
readonlyfield is stripped from a non-system INSERT, not only from a non-system update (b398ad2). The create-side strip used to live at the REST ingress alone, so a caller reachingengine.insertdirectly — the automation engine'screate_recordamong them — wrote a read-only column with no refusal and no warning. That column is now dropped, reported throughonFieldsDropped/droppedFields, logged atwarn, and refused outright understrictReadonlyWrites. ⚠️ This changes behaviour on a running deployment with nothing to parse-fail on. Seeding a read-only column at create time is a system act: usecontext.isSystem, a flow'srunAs: 'system', a system hook or a seed. positions[]on the session payload is the security axis (8e0b297, #15136).customSessionbuilt that array by comma-splitting a better-auth column this platform does not own, and read nothing fromsys_user_position— so a Console gate narrowed by a business position answered FALSE for everyone, including the person who held it, silently and in the invisible direction. The hand-rolled derivation is deleted; the payload now comes from the one authority every server-side evaluator already used, and agrees with/auth/me/permissionsset for set. No key is renamed and none is added.driver-turso'stimeoutis delivered or refused, and two inert keys are gone (ed5d557,bc0ac1d,5071310,0145680, #16024).timeoutnow bounds remote operations;localPathandwasmleave the published config schema; and every corner wheretimeoutcould be accepted and never delivered — beside awss://orws://url in any case, beside a pre-configuredclientin remote mode — is refused at construction.- Analytics date ranges close to the dashboard preset vocabulary
(
233222e, #16041).timeDimensions[].dateRange's string arm accepted any string, and its own documented example was a value no driver could parse. It now accepts exactly the declared preset names or the[start, end]array arm; anything else is400 ANALYTICS_DATE_RANGE_UNRECOGNIZED.os lintgained the matching author-time refusal on date-typed comparands (9f890d3, #16106). - One id space per flow.
FlowSchemarefuses a duplicate id in the top-levelnodes[](53cf263, #15713), a duplicate id inedges[](52804cd, #14964), and a region node whose id is already declared anywhere else in the flow (21aabbc, #16134). Expression slots move with them: an evaluated slot requires a non-blanksource(ef3a138, #15430) and a typed slot fixes its dialect on the envelope arm and refuses a blank string (f81afe3, #15028, #15035). - The record handed to a record-change flow is a copy, not the write's
payload (
4f85e4d, #14744). Ascriptnode mutating a nested value in place used to write the engine's own object — on amulti: trueupdate, the shared SET clause, so one dispatch's contribution landed on every matched row without assigning a single key. Both flow-facing roots are decoupled before the flow runs. - The client SDK declares the wire shapes better-auth actually sends — the
organizations.*(7092d63, #14314),auth.*(b1b978c, #14313) andoauth.*(e944fdb, #14312) families lose their publishedanyreturns, andoauth.applications.deleteresolves on the zero-byte 200 its route answers instead of rejecting on every successful delete (7beaaa3, #15451). - The multi-organization runtime ships as open source (
c677cda).@objectstack/organizationsis a published package rather than a closed half of the platform. - A flow can refuse with per-record text (
3f89967): theendnode gainsoutcomeand an interpolatedmessage, and the run vocabulary gainsrefused— a business rejection stops having to present itself as a failure. - Console: two objectui pin moves —
00d3f09c500c → a472b07167a3 → 53ded82bf7a4(236f2df,30b0990) — carrying six declared-breaking frontend entries for Console hosts and authors.
What's new in 17.4.0
17.4.0 was published to the latest tag on 2026-09-09, five days after
17.3.0, moving the whole version-locked train and no major. The bundled Console
advances two pins, 00d3f09c500c → a472b07167a3 → 53ded82bf7a4.
⚠️ 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. Two things in this release change behaviour on a running
deployment with nothing to parse-fail on — the read-only strip on non-system
creates, and the meaning of positions[] on the session payload — and the
duration-key wave renames authored keys across seven schema families with no
aliases at all.
Breaking changes & migration in 17.4.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 two Console pin refreshes are named once under New in
Console rather than enumerated
here.
Every duration key carries its unit in its name (#14478, ruling B)
check:duration-unit-keys is a new gate: a duration-shaped z.number() must
carry its unit in the key name, never only in its .describe() prose. It
grandfathers no existing offender, so the whole tree moved in one wave
(e9fcd6b, #15676–#15682, #14519, #16023).
| you wrote | write instead |
|---|---|
hook.timeout, job.timeout, DriverOptions.timeout | timeoutMs |
dashboard.refreshInterval | refreshIntervalSeconds |
tenant idleTimeout / sessionTimeout | idleTimeoutSeconds / sessionTimeoutSeconds |
MetadataManagerConfig.cache.databaseLoader.ttl | cache.databaseLoader.ttlMs |
ApiEndpoint.cacheTtl, RestApiEndpoint.cacheTtl, DataLoaderConfig.cacheTtl | cacheTtlSeconds |
RestApiEndpoint.timeout | timeoutMs |
EnhancedApiError.retryAfter | retryAfterSeconds |
DeviceRequestResponse.interval | intervalSeconds |
The kernel/, system/, data/, ui/, ai/ and integration/ families move
under the same rule; driver-turso's published connection config and
driver-memory's file-persistence auto-save interval move with them.
Migration. Re-run os validate — the authorable surface has been strict
since 17.0.0, so each of these is a refusal naming the key rather than a silent
drop. The old spellings are tombstoned, not aliased, and the hand-migration
prescriptions are registered under protocol major 18. The three inert outer keys
of MetadataManagerConfig.cache — enabled, ttlSeconds (formerly ttl) and
maxSize — are removed outright rather than renamed, because nothing read them
(7629f4d, #15624, ADR-0049); cache.databaseLoader is the live half.
A static readonly field is stripped from a non-system INSERT (b398ad2)
readonly meant one thing on insert and another on update. Every external
REST / GraphQL / MCP create was stripped at the protocol ingress, while a caller
reaching engine.insert directly wrote the column with no refusal, no warning
and no dropped-field event. The ingress strip is deleted rather than kept as
a second implementation: every create face — createData, cloneData,
createManyData, insertManyData and batchData's creating rows — now hands
the payload to the engine whole and reports the engine's own verdict in
droppedFields.
Migration. Search for creates that seed a read-only column and declare them
trusted: context.isSystem, a flow's runAs: 'system', a system hook or a
seed. A flow's create_record node without runAs: 'system' starts reporting a
node warning and output.droppedFields where it used to succeed cleanly. A
stripped key's defaultValue is re-derived, so a forged status column becomes
its declared default rather than NULL. os lint reports the shape ahead of
runtime: flow-update-readonly-field and hook-api-update-readonly-field now
carry an error-severity finding for a non-system create (720bf47), which
fails os lint / os validate / os build on a stack they used to accept.
cloneData forwards whole and reports on the wire too, through a new optional
droppedFields member on CloneDataResponseSchema (e08892d). Unchanged
deliberately: preserveAudit is still an update-path exemption, runtime-owned
types keep their own pass, and readonlyWhen still has no create-side strip.
positions[] on the session payload is the security axis (#15136)
user.positions in GET /api/v1/auth/get-session used to be assembled by
hand — a comma-split of a better-auth identity column this platform does not
own, plus the active membership, plus platform_admin — and read nothing from
sys_user_position, the ADR-0057 D4 table that is the source of truth. The
Console binds that array straight through as the CEL root current_user, so an
action.visible, visibleWhen, nav visible or page-tab gate narrowed by a
business position answered FALSE for everyone.
⭐ It failed silently and in the invisible direction: the key was present, so
has(current_user.positions) was true, CEL raised nothing, and the predicate
simply returned FALSE. A predicate that faults fails open in the shell and
would have shown the button; a successful FALSE shows nothing and reports
nothing. The documented example ('org_admin' in current_user.positions) kept
working throughout, because org_admin is the one name sitting on both axes.
Migration. No key is renamed and none is added — the payload now carries the
sys_user_position assignments and the ADR-0090 D5 everyone anchor, and
isPlatformAdmin is derived from the same array. A predicate that was written
against the old union, or a test pinning its members, is what moves. A consumer
that genuinely wants the third-party identity scalar reads it from its own
published key, which is unchanged.
driver-turso's timeout is delivered or refused (#16024, ADR-0049)
TursoDriverConfig.timeout was declared with a describe promising behaviour no
code delivered. The maintainer ruled per key: forward timeout, remove
localPath and wasm (ed5d557). None of the three is renamed — an inert key
with a better name is what ADR-0049 exists to prevent. The corners where the key
could still be accepted and never delivered are now refused at construction:
beside a wss:// or ws:// url (bc0ac1d), beside the same url spelled in
upper case in forced remote mode (0145680), and beside a pre-configured
client in remote mode (5071310).
Migration. Delete localPath and wasm from the driver config. If a
datasource declares timeout together with a websocket url or a pre-built
client, drop one of the two — construction now refuses the pair instead of
accepting it and ignoring the bound.
Analytics date ranges close to a declared vocabulary (#16041)
AnalyticsQuerySchema.timeDimensions[].dateRange — and with it the
POST /analytics/query and /analytics/sql bodies and the AnalyticsQuery
type every driver is compiled against — accepted any string. It now accepts
exactly the dashboard date-range preset names or the [start, end] array arm;
anything else is refused 400 ANALYTICS_DATE_RANGE_UNRECOGNIZED (233222e).
Migration. Replace free-text ranges with a declared preset name or an
explicit [start, end] pair. os lint refuses a preset name used as a
comparand on a declared date / datetime field in every position — bare,
$eq / $ne, $in / $nin and their view-rule and triple spellings — and
prescribes the interpolated form (9f890d3, #16106), so the authoring-time gap
that let a filter through to an INVALID_FILTER on first render is closed.
Resolution moved with the vocabulary: driver-memory resolves a string
dateRange on the UTC calendar (5f4f1f6, #15825) and honours
AnalyticsQuery.timezone when one is declared (1cf7392, #16042).
One id space per flow, and expression slots that must say something
FlowSchemarefuses a duplicate node id in the top-levelnodes[](53cf263, #15713), a duplicate edge id inedges[](52804cd, #14964), and a region node whose id is already declared elsewhere in the flow (21aabbc, #16134) — one node-id space across the top-level list and every region body.- An evaluated expression slot requires a non-blank
source(ef3a138, #15430), and a typed expression slot fixes its dialect on the envelope arm and refuses a blank string (f81afe3, #15028, #15035).
Migration. All five are parse-time refusals naming the offending id or slot.
Re-run os validate; a flow that authored two nodes with one id was already
ambiguous about which one an edge addressed.
The record a record-change flow receives is a copy (#14744)
buildContext built the flow's record as a shallow overlay, so the top-level
object was new but every nested value inside it was the engine's own object,
shared by reference. One of those is ctx.input.data — and on a multi: true
update every per-row context receives that same payload object, which is the SET
clause of the single updateMany. A registered function doing
record.tags.push(...) therefore wrote the SET clause without assigning any
key: every dispatch's contribution landed on every matched row, including
values derived from another row's pre-image, and the existing key-set refusal
could not see it because no key was assigned. Both flow-facing roots — record
(with its params alias) and previous — are now decoupled from engine state
before the flow runs (4f85e4d).
Migration. A script node that mutated a nested value in place to affect the
write must assign instead. Nothing metadata-shaped moves: no key, no schema, no
stored row.
The client SDK declares what the server sends
Four published contracts stop being any or wrong. The organizations.*
(7092d63, #14314), auth.* (b1b978c, #14313) and oauth.*
(e944fdb, #14312) families declare the wire shapes better-auth sends, and
oauth.applications.delete resolves on the zero-byte 200 its route answers
instead of rejecting on every successful delete (7beaaa3, #15451).
Migration. A TypeScript caller reading a member the route never sent now
sees it at build time — that red is the contract arriving, not a regression. A
caller that wrapped oauth.applications.delete in a catch to swallow its
spurious rejection can drop the wrapper.
Author-time refusals that can fail a stack which built clean on 17.3.0
Re-run os validate / os build after upgrading. Each of these is a parse-time
refusal naming the key, not a silent drop:
<ListView objectName>and<ListView viewType>are retired from the react-tier component contract (e0af1a8, #14791). Writedata={{ provider: 'object', object: 'account' }}andtypeinstead;validate-react-page-propsrefuses either retired spelling with areact-prop-retirederror where it used to warn and accept, and a react page with no binding at all is refused too.ComponentPropsMap['element:record_picker'].filterconverges onto theViewFilterRulearray form (414c1fc, #14406) — the last record-formfilterin the map.GanttConfigSchemaandTreeConfigSchemarefuse undeclared keys (9c270bb, #15469): both passthrough windows are closed, and the ten gantt members that were read through the window are declared by name.- A
treefield'sreference, when present, must name the declaring object (fb77aa5, #14892); a whitespace-onlyreferenceonlookup/master_detailis refused too (859ded3). pluginsanddevPluginsare artifact envelope keys — excluded from the assembled package body and refused insidepackages[](8f404a5, #15219).composeStackswithobjectConflict: 'merge'refuses object pairs whose object-level collections cannot be merged (64bd6a3, #14848) rather than producing a silently lossy merge.- The incident-response, training and change-management families are retired
whole — nineteen defs and every name they exported — together with the
ESignatureConfigdeadline pair (c463d03, #15513, #14477, ADR-0049), andconnector.errorMapping's eleven authorable keys go with them, one of them spelled like the liveuserMessagechannel (13c48c2, #14676). The whole migration is "delete the key". - A non-member schedule
timezoneno longer discards the cron expression (afa3a26, #16291): the schedule is refused, and one already holding an off-vocabulary zone stops instead of firing on a cadence nobody asked for.sys_job.timezone,sys_report_schedule.timezone,sys_business_unit.timezoneandsys_organization.timezoneare all validated against the IANA domain (0a038cc,6acb37e, #15872, #14238).
Runtime and driver behaviour changes worth checking
- A batch row reports a unique-constraint refusal as
UNIQUE_VIOLATION(65846bc, #14723), where it reportedDUPLICATE_RECORD. The whole-request failure on the same route already answeredUNIQUE_VIOLATION, so two spellings of one condition sat side by side in one route's responses. Pin the new one. - An array comparand is refused instead of compared by reference, and a
Datecomparand is compared by time value (10d05bb) ondriver-memory. - A text operator over a field whose declared type can never store a string
is refused at the engine's field-aware door (
6f1ce7d, #15661), and the three SQL compilers lower the same contract rows (a646120). findWithWindowFunctions()presents its rows like every other read door — a declared boolean answerstrue, not1(78bc4ad, #16609).- Datetime columns answer the canonical instant text on every dialect. The
builtin audit stamps and every declared
Field.datetimecolumn render asYYYY-MM-DDTHH:MM:SS.sssZon Postgres and MySQL as they always have on SQLite (45cfa1b, ADR-0053 addendum, #13973). ObjectQL.find()guarantees the array it declares — anafterFindhook that replaces the result container is refused withFIND_HOOK_RESULT_NOT_ARRAY(7778115).Field.valueDomainbinds at the write seam — a non-member is refused withvalue_domain(fa125f3), and the settings door answers from the same one predicate (6b8c677, #15162).- Dispatcher domain routes stop claiming their lexical neighbours:
DomainRoute.matchdefaults to'segment', so/authno longer answers for/authxor/authentication/foo(ce8bfc9,c1eafe6).
Smaller breaking changes in 17.4.0
kernel.use()andLiteKernel.use()enforce the declared plugin contract (2025b1f,51ae731): a plugin objectPluginSchemarefuses is refused at load on both published kernels instead of being stored and mounted. Auiplugin must now declarestaticPathandslug(2a3decc).ResolvedAuthzContext.authRefusalis removed — a published member nothing ever read (b0529e1, #14273).os create exampleis retired; the refusal namesos init(c8e5ac6, #16483, #15531).os create <type> <name>refuses a project name npm refuses, and refuses it before it writes anything (cee3961).os create pluginderives the exported symbol as a JavaScript identifier rather than copying the project name into an identifier position (ce8caba).os lint --generatorrefuses to run without--evalinstead of accepting the flag and ignoring it (be75493), andos lint --eval --generator ""refuses rather than quietly running the offline eval (ffe058a).GET /api/v1/packages,GET /api/v1/packages/:idandDELETE /api/v1/packages/:idhave one implementation — the runtime dispatcher's/packagesdomain.@objectstack/restmountsPOST /api/v1/packages/publishand nothing else (7370989).POST /packages/:id/duplicaterefuses a source that is not a writable base instead of answering200with an empty copy (bca21f7).- Every
metadata.endpoints.*switch gates exactly the face its name states, and the whole-store operations get their ownmaintenancekey (784cb92, #15542, #15854). - Published
.js.mapfiles no longer embed the complete original source text (c5d6803).sourcesContentwas an esbuild default nobody had chosen; every package's full source,@internalcomments included, was shipping to npm inside its source maps.
New capabilities in 17.4.0
Authoring gates. os lint --strict makes warning-severity findings fail the
run, so an app can rely on the platform's warning-level rules as its own gate
instead of re-implementing them (2da2901, #15935). The publish door refuses an
object whose highlightFields, searchableFields or built-in list-view
columns name a field the object does not have (615fac3, b4b37e5) — the
same gate that already refused a code-authored stack. A flow predicate authored
as a CEL envelope, and a flow condition that is neither CEL text nor an
expression, are refused at build time instead of running unread or answering a
silent false (56fe8c2, 9408b7f). flow-filter-token-unknown makes an
unresolvable {…} filter token an authoring-time error (d61bad0). Hooks
authored as inline handler functions are lowered before parsing, so
objectstack validate judges them by the same write-set rules as build and
lint (0cde37d, 6b7d709). New advisory field-no-consumers reports a field
nothing in the stack reads or displays (a87163c), and the widget rules cover
every widget family rather than charts alone (cd55558, 36a16d0, c01b3a6).
Automation and approvals. A flow can refuse with per-record text: the end
node gains outcome and an interpolated message, and the run vocabulary gains
refused (3f89967). An assignment value may be a CEL envelope, validated at
registerFlow, at objectstack validate and at the runtime publish gate
(954cb0b, #15137). A screen flow can be completed by a headless caller, and
list_actions publishes its input names (b31ebfe). Two operator run-lifecycle
verbs get doors and contracts — cancel and restore-suspension, gated on the
platform operator (44c849c, abb140c, #16495). A resume that consumed its
pause and then failed downstream answers status: 'stranded'
(5964124, #13937), the resume route's 400 FLOW_FAILED says whether the run
is stranded (68437d4), and the inspection tells a repairable strand from
a cascade-failed run through a read-only engine member rather than the wire
(4c31f02, #15358). A contained per-iteration failure shows at run level,
attributed to its iteration and bound to its row (d30ccb9); the step log tells
a parallel branch from a loop row (65ec530); ExecutionStepMetrics gains
a failures slot (68d5dfd); evaluateCondition refuses a malformed condition
shape with the same registration refusal rather than a raw TypeError or a
silent false (5a91387). A scheduled flow is delivered once per tick window,
and replaying a delivered window is refused (8341ed2). An approval decision
that lands while its run strands says so in fields (3d3f60e), and a restored
suspension can be decided again rather than only cancelled (6530e04).
Analytics. A dataset measure stops contradicting the value beside it: a
min / max over a temporal field is described as time and over a
string-valued field as string, and a draft preview answers the operand's own
type (07f40e5, 6136293, 6573af9, #15768, #16098). The aggregate ×
field-type compatibility matrix is declared rather than implied
(6ba0db4, #16353), with boolean / toggle admitted for the numeric
aggregates (ed7243d, #16685). The SQL compilers lower the case-sensitive text
family per dialect, so a $contains policy on SQLite stops admitting rows it
excludes (54bb2f1, #15684).
Tenancy and identity. @objectstack/organizations ships as open source
(c677cda). The organization hierarchy carries the IANA zone a date boundary is
computed in (6acb37e, #14238). "Remove member" now actually signs the person
out — sys_session.revoke_reason accepts organization_membership_ended
(4ca358d) — and sys_organization admits generic update on the data door,
column-gated by the ADR-0092 D2 identity write guard (7797102, #15873). MCP
OAuth can complete again: the MCP resource is registered as an RFC 8707 resource
and DCR-registered clients are linked to it (142c01c). POST /sign-up/email
for an address that already has a sys_user row is refused explicitly instead
of answering 200 for a row that is never written (aedbaef, #15587).
GET /auth/me/localization answers the signed-in user's own language
(5f7fa1d, #14788), the authored label is the default locale's text
(46803fa, #15711), and the declared fallback locale is readable so the
metadata-document translators get the chain the deployment declared
(a84e1ce, #14882).
Sharing. The field sharing recipient is enforced, expanded once per
matched record (9fa5775), and publicSharing.enabled has one canonical
predicate exported from the package that declares the key (4db3c61).
Errors and refusals. Engine refusals declare their HTTP status under both
spellings (33388f9), and the codes a caller wants to branch on become
importable constants rather than re-spelled strings (11f848e, 6d7d740,
ddfbf04, e6279dc, eddd612, 48b0fcf). Every defineStack refusal carries
an ADR-0112 envelope — six new STACK_* codes (1ecee3e, #15963) — and an
install-time namespace collision answers NAMESPACE_CONFLICT (92b5d7f). Every
error code that ships in dist is registered in ERROR_CODE_LEDGER
(abae16a, 613bfbd, #16449, #16649). A refusal follows the request's language:
accept-language: zh reads a Chinese refusal on a response whose labels are
already Chinese (f7ffbd6).
Packages and metadata. A multi-package artifact keeps its shape:
COMPOSE_KEY_DISPOSITIONS and STACK_DEFINITION_KEYS are derivable from one
source instead of hand-copied per consumer (69602e5, #14877). The caller's
_packageId / _packageVersion / _provenance are no longer persisted; tenant
authorship is restated at hydration for every metadata type (d2b6fa0). Two
files sharing one stem are refused with both paths named, instead of one being
served by extension precedence (c1d274d, #14921). Seed datasets gain a
locale filter axis composed with env by the loader (c1d8f98). Package
lifecycle state and os serve's runtime state file are keyed by the project as
well as the environment, so two projects on one machine stop overwriting each
other (f2f6684, acab609). The ADR-0030 notification cut-over records itself
in the sys_migration ledger and is nameable from @objectstack/spec/system
(8647c87, c2a336c, a7da4de).
CLI and scaffolding. @objectstack/cli ratifies ./hook-body as a public
subpath — extractHookBody and its three companions, so an app's hook-fidelity
harness runs the same body-only lowering os build ships, rather than a
lookalike — and admits ./package.json so the ordinary manifest-reading idiom
resolves again (95d5cbb, #15325); ./console gets a public barrel with a
name-and-shape pin (25011b0). os generate migration emits the character
column driver-sql actually creates, in both formats (000fd05). os migrate summary-nulls --recompute-undefined-on-empty fills a roll-up column a caller
knows was just declared (ec0a6e7, #15064). os create emits a project that
installs outside this monorepo (cf6b671), and scaffolded projects ship a CI
workflow, a lint script and the AI skills bundle (4998efa, fd75728,
813d6c5).
Runtime surface. GET /api/v1/health answers 200 whenever the process can
serve HTTP, even while a configuration fault is making every other route 503
(3e560da). /discovery stops advertising a realtime service with no mounted
surface (6491463). The platform action route executes the declarative
row-level operation: 'update' action (8a12067, #14092), and an action whose
caller-scope record load was denied carries recordLoadDenied into the run
context (2c753fe, #15168). mountStorageRoutes composes the storage routes
onto a host-owned HTTP surface from a kernel with no http-server service
(dd2184a, #15169). createHonoApp mounts the auth surface where the auth
service actually serves, and refuses a prefix it cannot serve it under
(cfb64a6).
Declared, not yet enforced. action.ai.requiresConfirmation gets a real
contract stating that an AI-facing call on an action declaring the flag must
carry an explicit confirmation — but no door performs that refusal yet
(fe0d9a4). Setting the flag stops nothing until the runtime half lands; it is
recorded here so nobody reads the declaration as a gate.
Notable fixes in 17.4.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.
Tenancy walls that were not holding. On a wall-enforcing tenancy posture, an
organization-less or ex-member API key answered 200 and now answers 401
(a727043); a session whose active organization is no longer one the user
belongs to resolves with no active organization instead of that one's data
(d4f9b2a); and four server-side authorization sites stop deriving
platform-operator authority from a name in ExecutionContext.positions,
reading the ADR-0095 posture rung instead (ac6213e). The share-link, settings,
storage-download, datasource-admin and marketplace install-local doors all
derive the effective tenancy posture before resolving the caller, so the
posture-conditional API-key refusals actually apply there (2e35765,
2024eca, b8c82de, fb447b4, 6b66ec7), and the runtime dispatcher door
stops admitting a request on a posture it could not read (401e50a). Webhook
fan-out matches subscriptions on the organization dimension, closing a
cross-organization delivery on walled deployments (7ceb416). The MCP stdio
transport vets an API key's organization against the deployment's posture
instead of trusting the key's own stored claim (17f8604). A department
approver on a seeded business unit no longer routes the approval to another
organization's members (ea03c7c).
Audit stamps stop being caller-supplied. The audit binder stamps created_at
from the system clock and created_by from the session on an ordinary create,
so a value in the request body no longer survives a plain POST (7079694,
7783738, #15964, #16311), and a sandboxed hook body no longer launders an
untouched readonly field onto the row (ee32e1c).
Failures that used to pass silently. An authorization-store outage reaches
the caller as the 503 SERVICE_UNAVAILABLE it declares, on the storage download
doors and all four settings routes (ac9376a). A self-registration grant is
refused rather than silently redirected when a permission-set row is malformed
(9e9f03a). os i18n extract --check — in both its human and --json faces —
now actually compares the bundles; it used to exit 0 having compared nothing, on
a tree whose bundles had provably drifted (591f194, 309bee5). os verify no
longer reports a green run over a multi-package app it measured nothing about
(c550baf). os generate <type> <name> no longer exits 0 after writing
TypeScript the compiler cannot parse (1ea349f), and os init -t app,
os init -t plugin and os g object emit an object file that compiles
(212eaba). The Server is ready line reports the degraded boot it is standing
on instead of printing a green tick over it (4771bd9).
Installs and boots that failed outright. @objectstack/plugin-auth pins the
better-auth family to an exact version, so a fresh install of a published
release loads the auth plugin again (2e6a2ea). A plain unique index over
existing duplicate rows no longer kills the boot with the database's raw error,
on MySQL and on the shared path, and os migrate plan stops calling that
operation safe (1ca95df, 61821e5). The published os binary no longer
freezes in the kernel or dies of an uncaught write EPIPE when whatever reads
its output stops draining (5023630, b3ef687).
Wire and query shapes. Analytics $icontains no longer compiles a
translate() call on the SQLite, MySQL and unknown dialect arms, where the
statement simply failed (dcad825, fd014b1). The public-form lookup picker
answers a search again instead of refusing every non-empty query with
400 INVALID_FILTER (d5c4022). An environment-scoped URL reaches its
dispatcher domain instead of answering 404 (da1cffb). The admin "Used by"
panel no longer clears a delete when the caller's own organization is using the
item (e13ede8). A published BulkDataEvent names the one organization the
tenant wall named for the batch (4b3955e). The field-required-notnull-explicit
ADR-0087 conversion is withdrawn: required: true no longer stamps
storage.notNull: true on anybody's fields (f2b5e46). createHostImporter
loads the import build of an aliased dual-published package instead of
silently keeping its require build (088f761). The seed loader stops putting
a raw control byte into a diagnostic line for a composite external id
(460d4b8).
Flow and template arithmetic. A map node inside a loop body runs its
collection on every iteration, not just the first (7bf96cf). A wizard screen
is no longer skipped after a durable pause because a record column happens to be
an array (4177ed3). {TODAY() + n} and {TODAY() - n} do their day
arithmetic on the calendar they render on (6c439f2). A run whose nodes all
succeeded is no longer reported stranded and re-armed because its terminal
history write threw (a775510), and a suspension consumed by another replica is
evicted so the run listings stop reporting phantoms (1157e7b, #15832).
driver-memory analytics stops counting the first instant of tomorrow into
dateRange: 'today' (db4bf90, #16179).
New in Console (Studio) — objectui pins in 17.4.0
Two pin moves, 00d3f09c500c → a472b07167a3 (236f2df) and
a472b07167a3 → 53ded82bf7a4 (30b0990), carry the console half of this
release. The per-commit lists are in packages/console/CHANGELOG.md under
## 17.4.0, which records the upstream objectui commit for every entry —
including the commits objectui shipped with no changeset of their own, listed by
subject there rather than counted.
⚠️ Console hosts and authors: six entries in this range are declared breaking upstream.
- Lookup and user widget metadata converge on the spec's camelCase — one
concept, one spelling (objectui
351eb3181). The snake-case members are removed; they were never an ObjectStack-authorable spelling. KanbanSchema/KanbanColumn/KanbanCardbelong to@object-ui/plugin-kanban(objectui2c71482ea), andSchemaRegistry['kanban']stops describing a component it cannot name (objectuibc640ec56).- The React chart primitives leave
@object-ui/components—ChartContainer,ChartTooltip,ChartTooltipContent,ChartLegend,ChartLegendContentandChartStyle(objectui7bf244bea). - The breakpoint layout vocabulary is re-homed and the two dead responsive
implementations are deleted (objectui
e62c44e7e) — objectui catching up to a retirement this side already made. MarkdownSchema.sanitizeandMarkdownSchema.componentsare retired (objectui446d93d4e, ADR-0049) on objectui's own SDUImarkdownnode. Themarkdownspelling ObjectStack declares is a long-text field type and carries neither key.
Neither pin moves an ObjectStack authorable key: @objectstack/console publishes
a frozen prebuilt SPA and re-exports no @object-ui/* type, so no stored
metadata row and no os migrate meta prescription follows from either bump.
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.4.0
⛔ Nobody has walked 17.3.0 → 17.4.0. Every line below is derived from a change's own Migration note in Breaking changes & migration in 17.4.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.
Getting onto the release
- Move all the
@objectstack/*pins as one set, regenerate the lockfile, and leavespecVersion/engines.protocolalone — this is a move inside one major, so the protocol declarations do not change. The full three-step procedure is Moving the dependency pins. Not exercised on this hop. - Do not read
os migrate meta --from 17reportingNothing to migrateas completion. Its scope is the protocol chain; every rename in this release is registered under protocol major 18, and everything else on this list is outside it. Not exercised on this hop; the 17.3.0 list records what that command answered on the previous one.
Metadata and build — run os validate before you ship
- Rename every duration key (#14478).
hook.timeout/job.timeout/DriverOptions.timeout→timeoutMs;dashboard.refreshInterval→refreshIntervalSeconds; tenantidleTimeout/sessionTimeout→*Seconds;MetadataManagerConfig.cache.databaseLoader.ttl→ttlMs; theapi/,kernel/,system/,data/,ui/,ai/andintegration/families with them. DeleteMetadataManagerConfig.cache's outerenabled,ttlSecondsandmaxSize— they are removed, not renamed. There are no aliases; each one is a refusal naming the key. Not exercised. - Move react-page
<ListView>bindings todata={{ provider: 'object', object }}andtype(#14791) — the retired props are now an error, not a warning. Not exercised. - Delete the retired keys: the incident-response, training and
change-management families, the
ESignatureConfigdeadline pair,connector.errorMapping, anddriver-turso'slocalPath/wasm. The whole migration is "delete the key". Not exercised. - Re-run
os validatefor the rest of the author-time refusals — duplicate flow node and edge ids, region ids colliding with the top-level node space, blank expressionsource, undeclared keys onGanttConfigSchema/TreeConfigSchema, atreefield'sreferencethat names another object, a whitespace-onlyreference,plugins/devPluginsinsidepackages[], an unmergeablecomposeStacksobject pair, and a scheduletimezoneoutside the IANA domain. Full list under Author-time refusals that can fail a stack which built clean on 17.3.0. Not exercised. - Expect new
os lintfindings you did not have. The non-system create of a staticreadonlyfield is a new error-severity finding, andfilter-preset-comparandnow refuses a dashboard date-range preset name in every comparand position on a declareddate/datetimefield. A deployment gating onos lintturns red before the runtime does. Not exercised.
Data and database
- A plain unique index over existing duplicate rows no longer kills the
boot, and
os migrate planstops calling that operationsafe— re-read the plan rather than assuming the previous verdict. Not exercised. - Datetime columns answer the canonical instant text on Postgres and MySQL as they always have on SQLite (ADR-0053 addendum). A consumer parsing the previous dialect-native rendering moves. Not exercised.
required: trueno longer stampsstorage.notNull: true— thefield-required-notnull-explicitconversion is withdrawn. Declarestorage.notNullwhere you want it. Not exercised.
Deployment and configuration
- ⛔
driver-tursorefusestimeoutbeside a websocket url or a pre-configured client, at construction. A datasource declaring that pair stops the driver from being built at all — drop one of the two before you deploy (#16024). Not exercised. - A tenancy wall that was leaking starts refusing. On
OS_TENANCY_POSTURE=grouporisolated, an organization-less or ex-member API key moves from200to401, a session whose active organization the user has left resolves with none, and webhook fan-out stops crossing organizations. If a fixture or an integration leaned on any of those, it goes red. Not exercised. - An authorization-store outage now answers
503on the storage download doors and all four settings routes, where it previously degraded quietly. Not exercised. - Analytics callers must send a declared date-range preset or an explicit
[start, end]pair (#16041); any other string is400 ANALYTICS_DATE_RANGE_UNRECOGNIZED. Not exercised. @objectstack/plugin-authpinsbetter-authexactly. If your deployment pins its own copy of that family, reconcile the two before installing. Not exercised.
Application code, hooks and flows
- ⛔ Find every non-system create that seeds a read-only column (
b398ad2) and declare it trusted —context.isSystem,runAs: 'system', a system hook or a seed. This one changes behaviour on a running deployment with nothing to parse-fail on: the column is dropped,droppedFieldsreports it, andstrictReadonlyWritesrefuses the write outright. A flow'screate_recordnode starts reporting a node warning where it used to succeed. Not exercised. - ⛔ Re-read every predicate written against
current_user.positions(#15136). The array's meaning moves from a comma-split identity scalar to the security axis, so a gate that answered FALSE for everyone starts answering correctly — and a predicate that leaned on the old union's extra tokens stops matching. Tests pinning the old members go red. Not exercised. - Search flow
scriptnodes for in-place mutation of the triggering record (#14744). A nested mutation no longer reaches the write; assign instead. On amulti: trueupdate this was writing every matched row, so the fix can change what your data looks like after an upgrade. Not exercised. - Repin batch-row error codes: a unique-constraint refusal on
POST /api/v1/data/:object/batchreportsUNIQUE_VIOLATION, notDUPLICATE_RECORD(#14723). Not exercised. - A caller-supplied
created_at/created_byno longer survives a plainPOST. Any importer or fixture that set them on an ordinary create must move to a system write. Not exercised. - Recompile against the client SDK's declared response contracts (#14312,
#14313, #14314). A member the route never sent is now a build error; that red
is the contract arriving.
oauth.applications.deletestops rejecting on success, so a catch that swallowed it can go. Not exercised. - A plugin object the schema refuses is refused at load on both kernels, and
a
uiplugin must declarestaticPathandslug. A plugin that was stored and mounted despite a malformed declaration stops loading. Not exercised.