ObjectStackObjectStack

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.timeout become timeoutMs, dashboard.refreshInterval becomes refreshIntervalSeconds, the tenant session pair becomes idleTimeoutSeconds / sessionTimeoutSeconds, and the api/, kernel/, system/, data/, ui/, ai/ and integration/ families move with them (#15676–#15682, #14519). check:duration-unit-keys grandfathers 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 readonly field 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 reaching engine.insert directly — the automation engine's create_record among them — wrote a read-only column with no refusal and no warning. That column is now dropped, reported through onFieldsDropped / droppedFields, logged at warn, and refused outright under strictReadonlyWrites. ⚠️ 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: use context.isSystem, a flow's runAs: 'system', a system hook or a seed.
  • positions[] on the session payload is the security axis (8e0b297, #15136). customSession built that array by comma-splitting a better-auth column this platform does not own, and read nothing from sys_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/permissions set for set. No key is renamed and none is added.
  • driver-turso's timeout is delivered or refused, and two inert keys are gone (ed5d557, bc0ac1d, 5071310, 0145680, #16024). timeout now bounds remote operations; localPath and wasm leave the published config schema; and every corner where timeout could be accepted and never delivered — beside a wss:// or ws:// url in any case, beside a pre-configured client in 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 is 400 ANALYTICS_DATE_RANGE_UNRECOGNIZED. os lint gained the matching author-time refusal on date-typed comparands (9f890d3, #16106).
  • One id space per flow. FlowSchema refuses a duplicate id in the top-level nodes[] (53cf263, #15713), a duplicate id in edges[] (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-blank source (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). A script node mutating a nested value in place used to write the engine's own object — on a multi: true update, 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) and oauth.* (e944fdb, #14312) families lose their published any returns, and oauth.applications.delete resolves 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/organizations is a published package rather than a closed half of the platform.
  • A flow can refuse with per-record text (3f89967): the end node gains outcome and an interpolated message, and the run vocabulary gains refused — 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 wrotewrite instead
hook.timeout, job.timeout, DriverOptions.timeouttimeoutMs
dashboard.refreshIntervalrefreshIntervalSeconds
tenant idleTimeout / sessionTimeoutidleTimeoutSeconds / sessionTimeoutSeconds
MetadataManagerConfig.cache.databaseLoader.ttlcache.databaseLoader.ttlMs
ApiEndpoint.cacheTtl, RestApiEndpoint.cacheTtl, DataLoaderConfig.cacheTtlcacheTtlSeconds
RestApiEndpoint.timeouttimeoutMs
EnhancedApiError.retryAfterretryAfterSeconds
DeviceRequestResponse.intervalintervalSeconds

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

  • FlowSchema refuses a duplicate node id in the top-level nodes[] (53cf263, #15713), a duplicate edge id in edges[] (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). Write data={{ provider: 'object', object: 'account' }} and type instead; validate-react-page-props refuses either retired spelling with a react-prop-retired error where it used to warn and accept, and a react page with no binding at all is refused too.
  • ComponentPropsMap['element:record_picker'].filter converges onto the ViewFilterRule array form (414c1fc, #14406) — the last record-form filter in the map.
  • GanttConfigSchema and TreeConfigSchema refuse 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 tree field's reference, when present, must name the declaring object (fb77aa5, #14892); a whitespace-only reference on lookup / master_detail is refused too (859ded3).
  • plugins and devPlugins are artifact envelope keys — excluded from the assembled package body and refused inside packages[] (8f404a5, #15219).
  • composeStacks with objectConflict: '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 ESignatureConfig deadline pair (c463d03, #15513, #14477, ADR-0049), and connector.errorMapping's eleven authorable keys go with them, one of them spelled like the live userMessage channel (13c48c2, #14676). The whole migration is "delete the key".
  • A non-member schedule timezone no 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.timezone and sys_organization.timezone are 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 reported DUPLICATE_RECORD. The whole-request failure on the same route already answered UNIQUE_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 Date comparand is compared by time value (10d05bb) on driver-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 answers true, not 1 (78bc4ad, #16609).
  • Datetime columns answer the canonical instant text on every dialect. The builtin audit stamps and every declared Field.datetime column render as YYYY-MM-DDTHH:MM:SS.sssZ on Postgres and MySQL as they always have on SQLite (45cfa1b, ADR-0053 addendum, #13973).
  • ObjectQL.find() guarantees the array it declares — an afterFind hook that replaces the result container is refused with FIND_HOOK_RESULT_NOT_ARRAY (7778115).
  • Field.valueDomain binds at the write seam — a non-member is refused with value_domain (fa125f3), and the settings door answers from the same one predicate (6b8c677, #15162).
  • Dispatcher domain routes stop claiming their lexical neighbours: DomainRoute.match defaults to 'segment', so /auth no longer answers for /authx or /authentication/foo (ce8bfc9, c1eafe6).

Smaller breaking changes in 17.4.0

  • kernel.use() and LiteKernel.use() enforce the declared plugin contract (2025b1f, 51ae731): a plugin object PluginSchema refuses is refused at load on both published kernels instead of being stored and mounted. A ui plugin must now declare staticPath and slug (2a3decc).
  • ResolvedAuthzContext.authRefusal is removed — a published member nothing ever read (b0529e1, #14273).
  • os create example is retired; the refusal names os init (c8e5ac6, #16483, #15531).
  • os create <type> <name> refuses a project name npm refuses, and refuses it before it writes anything (cee3961). os create plugin derives the exported symbol as a JavaScript identifier rather than copying the project name into an identifier position (ce8caba).
  • os lint --generator refuses to run without --eval instead of accepting the flag and ignoring it (be75493), and os lint --eval --generator "" refuses rather than quietly running the offline eval (ffe058a).
  • GET /api/v1/packages, GET /api/v1/packages/:id and DELETE /api/v1/packages/:id have one implementation — the runtime dispatcher's /packages domain. @objectstack/rest mounts POST /api/v1/packages/publish and nothing else (7370989).
  • POST /packages/:id/duplicate refuses a source that is not a writable base instead of answering 200 with an empty copy (bca21f7).
  • Every metadata.endpoints.* switch gates exactly the face its name states, and the whole-store operations get their own maintenance key (784cb92, #15542, #15854).
  • Published .js.map files no longer embed the complete original source text (c5d6803). sourcesContent was an esbuild default nobody had chosen; every package's full source, @internal comments 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 / KanbanCard belong to @object-ui/plugin-kanban (objectui 2c71482ea), and SchemaRegistry['kanban'] stops describing a component it cannot name (objectui bc640ec56).
  • The React chart primitives leave @object-ui/components — ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent and ChartStyle (objectui 7bf244bea).
  • 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.sanitize and MarkdownSchema.components are retired (objectui 446d93d4e, ADR-0049) on objectui's own SDUI markdown node. The markdown spelling 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 leave specVersion / engines.protocol alone — 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 17 reporting Nothing to migrate as 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; tenant idleTimeout / sessionTimeout → *Seconds; MetadataManagerConfig.cache.databaseLoader.ttl → ttlMs; the api/, kernel/, system/, data/, ui/, ai/ and integration/ families with them. Delete MetadataManagerConfig.cache's outer enabled, ttlSeconds and maxSize — they are removed, not renamed. There are no aliases; each one is a refusal naming the key. Not exercised.
  • Move react-page <ListView> bindings to data={{ provider: 'object', object }} and type (#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 ESignatureConfig deadline pair, connector.errorMapping, and driver-turso's localPath / wasm. The whole migration is "delete the key". Not exercised.
  • Re-run os validate for the rest of the author-time refusals — duplicate flow node and edge ids, region ids colliding with the top-level node space, blank expression source, undeclared keys on GanttConfigSchema / TreeConfigSchema, a tree field's reference that names another object, a whitespace-only reference, plugins / devPlugins inside packages[], an unmergeable composeStacks object pair, and a schedule timezone outside 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 lint findings you did not have. The non-system create of a static readonly field is a new error-severity finding, and filter-preset-comparand now refuses a dashboard date-range preset name in every comparand position on a declared date / datetime field. A deployment gating on os lint turns 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 plan stops calling that operation safe — 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: true no longer stamps storage.notNull: true — the field-required-notnull-explicit conversion is withdrawn. Declare storage.notNull where you want it. Not exercised.

Deployment and configuration

  • ⛔ driver-turso refuses timeout beside 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=group or isolated, an organization-less or ex-member API key moves from 200 to 401, 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 503 on 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 is 400 ANALYTICS_DATE_RANGE_UNRECOGNIZED. Not exercised.
  • @objectstack/plugin-auth pins better-auth exactly. 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, droppedFields reports it, and strictReadonlyWrites refuses the write outright. A flow's create_record node 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 script nodes for in-place mutation of the triggering record (#14744). A nested mutation no longer reaches the write; assign instead. On a multi: true update 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/batch reports UNIQUE_VIOLATION, not DUPLICATE_RECORD (#14723). Not exercised.
  • A caller-supplied created_at / created_by no longer survives a plain POST. 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.delete stops 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 ui plugin must declare staticPath and slug. A plugin that was stored and mounted despite a malformed declaration stops loading. Not exercised.

On this page