ObjectStackObjectStack

v17.0.0

Files become platform records with governed download, bulk export becomes its own opt-in privilege, the SDK reaches every route the server actually mounts, approvals route approvers dynamically, and a boot that cannot reach its datasource stops pretending it can. Backend and Console notes for 17.0.0.

The v17 line is a truth-telling release. Where v16 made declared metadata honest, v17 does the same for the surfaces around it: files stop being inline blobs and become owned sys_file records with a governed download path; the export privilege stops being a free rider on read; the SDK stops shipping methods no server ever answered; a datasource that cannot connect stops booting clean and failing every query afterwards; and an approval request stops being readable by everyone in the tenant. Alongside that, agent.tools[], the GraphQL surface, the ObjectStackProtocol alias, and a long tail of parsed-but-never-enforced spec clusters are removed rather than maintained.

Release status: the 17.0.0 train ships through Changesets pre-mode, so it publishes as 17.0.0-rc.N — nothing reaches the latest tag until changeset pre exit. Section headings say 17.0.0 for brevity. Caret ranges on ^16.x hold at 16.x until you opt in, which is the reason this train is a major at all: its breaking density (the ApiMethod shrink, the GraphQL removal, the ADR-0104 write cutover, the dead-cluster retirements) is too high to auto-upgrade ^16.x consumers into on their next install.

Highlights — 17.0.0

  • A file is a record now, not a blob in a column. Media fields (file/image/avatar/video/audio) store an opaque sys_file id; the {url, name, size, …} object becomes the read (expanded) form. The platform owns the bytes, so accept / maxSize are declarable and server-enforced, downloads carry the real filename and content type, and read authorization can be delegated to the owning object instead of handed out as an unguessable URL. os migrate files-to-references performs the conversion with a self-check and records a per-deployment flag.
  • Bulk export is its own privilege. allowExport unset used to mean "inherit read". It now means denied. Reading a record and taking a machine-readable copy of the whole table are different acts — and viewAllRecords / modifyAllRecords no longer confer export either.
  • The SDK reaches the surface that exists — and only that surface. 21 dead methods (plus the entire phantom ai namespace) are deleted, the four ghost route tables that underwrote them go with them, and 40+ genuinely mounted routes the SDK could never reach are now typed: actions, keys, share links, security, package lifecycle, reports, approvals, record shares, sharing rules, search, ai.chat/ai.chatStream/ai.conversations.*, ai.agents.*, ai.pendingActions.*. A route-ledger conformance gate runs in both directions, so the two sets cannot drift apart again.
  • An approver can choose the next approver. Approval nodes gain expression approvers (CEL over current.* / trigger.* / vars.*), a node-level onEmptyApprovers policy (admin_rescue / fail / auto_approve), and declared decision outputs that resume the run as <nodeId>.<key> variables — so "the lead reviewer picks which departments co-review" is a declaration, not a record-field detour.
  • A broken datasource is loud at boot. A datasource that objects bind to must connect or the boot fails; objectql.init() refuses to start on a dead data driver; /ready answers 503 when one stops answering; and the default datasource is now a declaration travelling the same connect-and-verdict path as every other one, instead of a second copy of the policy.
  • An approval request is visible to its participants. getRequest / listRequests / countRequests applied only the tenant half of the visibility rule, so any authenticated user could read any request in their tenant — payload snapshot, decision history, and attachments.
  • A sharing rule with no criteria shares nothing, and a disabled RLS policy is disabled. Both were documented contracts whose real behaviour was wider: a rule stored without criteria evaluated as every record of the object (reachable by a typo through three unvalidated write paths, #3896), and an RLS policy switched off with enabled: false kept contributing its OR-branch grant. Both now fail closed — found by re-verifying every security-subset claim in the spec liveness ledger against the actual call graph, a sweep that also removed the void RLS priority knob and the never-wired plugin sandboxing/integrity config, and left two new CI gates behind the whole class: a permissive empty state must be classified on purpose, and the security entries now carry runtime proofs.
  • Node.js 22 is the floor. engines.node said >=18 across all 50 manifests while CI, the release pipeline and every shipped Docker image ran 22. The promise now matches the evidence.
  • The dead-metadata sweep continues. GraphQL, PortalSchema, AuditConfig, the capabilities-descriptor cluster, FeatureFlagSchema, SkillSchema.permissions, tool.requiresConfirmation, agent.tools[], object.enable.trash/mru, report aria/performance, DEFAULT_DISPATCHER_ROUTES, the four inert tool authoring keys (category/permissions/active/builtIn), the close-out sweep across action/flow/view/dashboard/agent/skill (fourteen more inert keys, flow.active and agent.knowledge among them) and the last three deprecated authorable aliases are removed — each one had been parsed and ignored.

Breaking changes & migration

Node.js 22 is the supported floor (#3825)

engines.node moves from >=18.0.0 to >=22.0.0 across all 50 published manifests. Node 18 reached end-of-life on 2025-04-30 and Node 20 on 2026-04-30, so the old range promised two runtimes nobody patches and nothing in the repo verifies.

nvm install 22 && nvm use 22

On Node 22 or newer, nothing changes — Node 24 and 26 both satisfy the range. npm and pnpm surface an unsatisfied engines as an EBADENGINE warning rather than a hard failure, so an existing install will not break the instant you upgrade; the package is simply no longer tested there. If your CI pins Node, pin it to 22 as well.

allowExport is opt-in — export no longer inherits read (#3544, #3710)

beforeafter
allowExport unsetexport allowed (inherited read)export denied
allowExport: falsedenieddenied (unchanged)
allowExport: trueallowedallowed (unchanged)

The one-line fix — add the grant to the object entry (or the '*' wildcard) of every permission set whose holders should keep exporting:

objects: {
  deal: { allowRead: true, allowExport: true },   // ← add the grant
}
  • Package-shipped sets are re-seeded on upgrade, so admin_full_access and organization_admin carry allowExport: true for you. Environment-authored sets are not — edit any custom set whose users export. member_default deliberately does not carry the grant, so ordinary authenticated users lose export until an admin grants it. That is the point of the flip.
  • Merge is most-permissive, exactly like the CRUD bits: any set granting true grants export; false and unset are the same outcome.
  • viewAllRecords / modifyAllRecords no longer imply export. Separating "may see all data" from "may take a bulk copy" is the segregation-of-duties case the axis exists for.
  • A set carrying allowExport is now high-privilege, so it cannot be bound to the everyone / guest audience anchors — otherwise the opt-in was defeatable by binding an exporting set to an anonymous anchor.

Read/CRUD/RLS/FLS/sharing are untouched, and reports are covered by the same axis.

enable.apiMethods shrinks to the six primitives (#3543)

The authorable enum is now exactly get, list, create, update, delete, bulk. The eight legacy values are derived effective operations resolved by the server's single derivation table, not things you declare.

FROM (legacy)TO (primitives)why
upsertcreate, updateupsert ⊆ create ∧ update
importcreate, updateimport ⊆ create ∨ update
exportlistexport ⊆ list
aggregatelistaggregate ⊆ list
searchlistsearch ⊆ list ∧ searchable
historygethistory ⊆ get ∧ trackHistory
restore(delete the value)never derives — enable.trash retired
purge(delete the value)never derives — enable.trash retired

Replace each legacy value with the primitives it derives from, de-duplicate, and if the result names all six, delete the apiMethods key entirely — that is equivalent to default-open and it tracks future primitives.

node scripts/codemod/apimethods-legacy-to-primitives.mjs

The codemod is a reporter: it scans, prints the exact replacement per site, and flags whitelists the mapping would widen so every edit stays reviewable.

Stored metadata keeps parsing. A stored legacy value is not a parse error — stripLegacyApiMethods removes it with a FROM→TO warning. Stripping only ever narrows. Watch one cliff: a whitelist of only legacy values (e.g. ['upsert']) strips to [], which is deny-all — the object's API closes rather than widening.

An action must be declared to be invocable, and is identified by name (ADR-0110, #3935)

Two halves of the /api/v1/actions/:object/:action contract were broken in complementary ways, which is why neither surfaced: the route resolved the declaration from the URL segment as a name, but dispatched the handler using that same segment as a registry key. For a target-bound action ({ name: 'complete_task', target: 'completeTask' }) those differ — so the documented curl .../todo_task/complete_task resolved the declaration and then 404ed, while the Console's target-addressed call dispatched fine and resolved no declaration, silently skipping the ADR-0066 D4 capability gate and the ADR-0104 param contract.

Three changes land together:

  • Identity is name. The URL, MCP run_action, and every future surface identify an action by its declarative name. target is a binding expression — polymorphic per type, ${param.X}-interpolatable, and legally non-unique — so it never identifies anything. The server derives the handler key from the declaration it resolved, using the rotation the MCP bridge already used. The Console posts name (objectui ships in lockstep).
  • An undeclared handler is refused. engine.registerAction with no matching declaration has no requiredPermissions to enforce and no param contract to check, yet executes with system privileges. It now returns 404 naming the defineAction to add. Deleting a declaration used to remove an action's gate while leaving it callable; removal now narrows.
  • An unreachable metadata plane refuses instead of degrading. A loader failure used to be indistinguishable from "no declaration", so an outage silently ungated every action it could not see. That is now a 503 — the same posture as the datasource entry below.

Migration: boot logs list every registered-but-undeclared handler under [action-governance], alongside declared script actions bound to no handler. Declare each one with defineAction, or drop the registration if nothing should invoke it over HTTP. There is no opt-out flag — a switch that ran an ungoverned handler would be the same fail-open this change closes, and a sweep of the platform, every package and every example found zero undeclared handlers, so it would have shipped a way to reopen the gate for a case nobody has observed. The failure is bounded: the app still boots and every declared action still works; only an undeclared one returns 404, and it names the defineAction to add.

Apps whose actions are all declared — anything with working Console buttons — need no changes, other than gaining enforcement of the requiredPermissions they already declared. Callers that hard-coded a target in an action URL switch to the action's name.

An action's declared params are enforced at dispatch (ADR-0104 D2, #3438)

An action's params[] (required, options, multiple, reference) was a complete contract that informed only the client dialog. The server passed reqBody.params to the handler unvalidated on both the REST and MCP paths, so a wrong bag could return a success envelope while the handler quietly ignored it — the #3405 shape, one layer out. It is now checked before the handler runs; a violation is 400 VALIDATION_FAILED (REST) or a thrown error (MCP).

ViolationExample
missing required paramdeclared p_text absent from the bag
value outside optionsp_priority: 'NOT_AN_OPTION'
multiple shapea bare string where an array is declared
reference shapea non-id where reference: 'sys_user' is declared
undeclared keybogus: 123
- OS_ACTION_PARAMS_STRICT_ENABLED=1   # removed — enforcement is the default
+ OS_ALLOW_LAX_ACTION_PARAMS=1        # escape hatch: warn and pass, as before

Only already-wrong calls break, and each rejection names the offending param and the declared list, so the fix is one edit at the call site. Actions declaring no params are untouched — there is nothing to validate against — and the dispatcher's own recordId / objectName are allowlisted, so keys dispatch merges in are never unknown-key errors. If an integration you cannot reach in time is affected, OS_ALLOW_LAX_ACTION_PARAMS=1 restores the old pass-through in one restart; the violation still logs once per action, so the drift stays visible rather than becoming invisible again.

The RC line carried this warn-first behind an OS_ACTION_PARAMS_STRICT_ENABLED opt-in. That variable does not exist in 17.0. The window was closed on purpose rather than deferred to 18.0: what a violation strands is a caller, not data — no stored row becomes unwritable, the party who sees the error is the party who can fix it, and the hatch makes it reversible. Deferring would have charged every deployment a second upgrade ceremony to postpone a break that costs one edited call. The value-shape half of the same ADR went the other way for the opposite reason: it rejects on the basis of data already at rest, so it stays gated per deployment (see Files become platform records below).

A flow run with no trigger user may not touch data (#3760)

An effective runAs: 'user' run that resolved no trigger user used to execute its data nodes unscoped — it presented no principal, the data security middleware skips when there is no principal, and the run then read and wrote every row. runAs: 'user' is an access-narrowing declaration; failing to resolve it must never resolve to a grant. It now refuses with UnscopedRunDataAccessError.

This was never really a schedules problem. isSystem does not suppress trigger dispatch — only skipTriggers does — so every plugin/service system write, the approvals status mirror, and a runAs: 'system' flow's own data node dispatched record-change flows with userId: undefined. Unprivileged input reached that path routinely.

Migration: a flow that reacts to system writes and must act beyond one user's grants declares runAs: 'system', making the elevation explicit and audit-attributable. Otherwise ensure the trigger supplies a user. Flows that touch no data are unaffected, the engine warns at run setup before any node executes, and the originating write still succeeds because the trigger already swallows flow errors.

The last three deprecated authorable aliases are removed (#3855)

RemovedUse insteadValue shape
action.executeaction.targetunchanged
field.conditionalRequiredfield.requiredWhenunchanged
agent.knowledge.topicsagent.knowledge.sources → the whole knowledge block was later removed (#3896 close-out — see the dead-cluster table)

All three are pure key renames — each alias was already lowered into its canonical key at parse time, so what shrinks is the authorable surface, not the semantics.

os migrate meta --from <your current major>

The renames are registered as protocol-17 chain steps, so one pass applies all three plus every earlier step you skipped. Manual alternative: rename the key.

- actions: [{ name: 'convert', type: 'script', execute: 'convertHandler' }]
+ actions: [{ name: 'convert', type: 'script', target: 'convertHandler' }]

- fields: { due_date: { type: 'date', conditionalRequired: 'record.stage == "closed"' } }
+ fields: { due_date: { type: 'date', requiredWhen: 'record.stage == "closed"' } }

- knowledge: { topics: ['faq', 'policies'], indexes: ['docs'] }
+ (delete the block — `agent.knowledge` was removed outright in the #3896
+  close-out: declaring sources never scoped retrieval)

Each removed key is tombstoned as never rather than simply deleted, so writing it is a tsc error at the authoring site and a parse error carrying the rename — none of these schemas is .strict(), so a plain deletion would have made Zod silently strip the key and the setting would have quietly stopped taking effect.

lintDeprecatedAliases and its rule-id exports are removed with them: that pass existed to warn when an author declared both an alias and its canonical key, which the parse now rejects outright. Delete the import; there is no replacement because the condition can no longer occur.

Seven flow-node config key aliases graduate into the conversion layer (#3796)

Node type(s)DeprecatedUse instead
get_record / create_record / update_record / delete_recordconfig.objectconfig.objectName
notifyconfig.to / config.subject / config.body / config.urlconfig.recipients / config.title / config.message / config.actionUrl
scriptconfig.functionName / config.inputconfig.function / config.inputs

All are pure key renames with unchanged values. Unlike the three tombstoned aliases above, these cannot be rejected in the schema — FlowNodeSchema.config is an unconstrained record — so they keep a load-path acceptance window instead: a stored flow authored with an alias keeps loading through protocol 17, rewritten to the canonical key at load (including AutomationEngine.registerFlow rehydration) with a ConversionNotice; the window retires in 18. The executors read canonical keys only, and the readAliasedConfig executor shim (which covered objectobjectName and warned at run time) is deleted — the conversion layer is now the single seam that declares, converts, and retires flow-config aliases.

os migrate meta --from <your current major> rewrites all seven in your source; or rename the keys by hand. actionUrl is the deliberate canonical of its pair: the downstream chain already uses that name (sys_notification.action_url, the channel contract, the REST notification model), while url platform-wide means an HTTP endpoint to call (http node, webhooks). The singular input on map / subflow / connector_action is those nodes' own canonical key and is untouched.

Authorable schemas reject unknown keys (#4001)

Zod's default is .strip: a key a schema does not declare is silently discarded and the instance keeps parsing. On an authorable surface that is the worst failure mode — the author (human or AI) gets a success envelope and ships metadata that quietly ignores their config; that is exactly how a correctly intended action-param reference: 'sys_user' degraded into a paste-a-UUID text box (#3405). 17.0 extends #3746's strictness from that one schema to the two highest-risk authorable surfaces, per the triage in docs/audits/2026-07-unknown-key-strictness-ledger.md:

  • Permission setsPermissionSetSchema, ObjectPermissionSchema, FieldPermissionSchema, AdminScopeSchema. A silently dropped key on the capability container meant the author believed a grant or restriction was in place that the runtime never saw. The response-side EffectiveObjectPermissionSchema stays wire-tolerant.
  • FlowsFlowSchema, FlowNodeSchema, FlowEdgeSchema, FlowVariableSchema. A node's config stays an open record: it is per-node-type, owned by the executor's configSchema and the conversion layer (see the alias table above).
  • RLS policiesRowLevelSecurityPolicySchema. A silently dropped key here meant a row-level restriction the author wrote was never compiled into the filter (the pre-ADR-0090 D3 vocabulary → positions, withCheckcheck, condition/filter/whereusing). The runtime evaluation shapes stay tolerant.
  • Sharing rules — the rule, its criteria extension, and the sharedWith recipient (criteriacondition, accessaccessLevel, recipientsharedWith; ownedBy carries the removed owner-type-rule prescription).
  • PositionsPositionSchema, including the guidance that permissionSets/users are runtime bindings and parent has no meaning on a deliberately flat position (ADR-0090 D3). Position also gains the protection block and ADR-0010 runtime envelope every sibling registered type already declared.
  • The app shellAppSchema, its branding / area / context-selector / contribution blocks, and the whole navigation tree. The nav-item union is now DISCRIMINATED on type, so one unknown key yields one precise issue against the branch you wrote (at an exact path through nested children) rather than a nine-branch invalid_union wall. Per-target payloads (params on page / component / action items) stay open. The gate's first catch here was in the platform's own Account app: three navigation groups declared defaultOpen — never a schema key — and so shipped collapsed while their author believed they opened by default (expanded is the key).
  • Approval nodes — all four authoring schemas (node config, approver, escalation, decision-output). Process-era keys carry the ADR-0019 re-home map (steps → successive approval nodes, entryCriteria → the entering edge's condition, onApprove/onReject → the approve/reject out-edges, rejectionBehavior → a declared back-edge). The published JSON schema carries additionalProperties: false into the Studio form and registerFlow() config validation, so a mis-keyed approval config is rejected at registration too.
  • HooksHookSchema, its retryPolicy, and both hook-body branches (expression and js). A body was the worst place to lose a key quietly: a misspelt capabilities stripped to the empty default and the sandbox then threw at invocation time, on whichever code path first touched ctx.api — far from the typo. A misspelt timeoutMs/memoryMb silently downgraded the body to the enclosing hook's looser limits. Two near-miss spellings now carry aliases because they are genuinely easy to cross: hook-level timeout vs body-level timeoutMs, and hook retryPolicy.backoffMs vs datasource retryPolicy.baseDelayMs. HookContextSchema — the runtime shape the engine hands your handler — stays tolerant and always will.
  • DatasourcesDatasourceSchema with its pool / healthCheck / ssl / retryPolicy blocks, the ADR-0015 external federation settings and their validation policy, DatasourceCapabilities, and DriverDefinitionSchema. config stays an open record: its shape is per-driver. What it no longer is, is unvalidated — #4410 wired the per-driver schemas (PostgresConfigSchema and siblings) into DatasourceSchema's refinement, so a misspelling one level down is now rejected with the canonical key named. An earlier version of this note said the driver's own configSchema did that, which was wrong for two releases: the field existed, nothing read it. That openness is why the top level had to close — a connection key written one level too high (host next to driver instead of inside config) was stripped, and the datasource then connected on driver defaults rather than failing. Those keys now prescribe the move into config; a top-level password is instead pointed at external.credentialsRef, because relocating an inlined secret is not the fix. A dropped key in capabilities was quieter still — though not for the reason this note used to give. It claimed an unregistered capability "reads as false, so the engine stopped pushing that work down to the driver and recomputed it in memory", which was never true: the #4487 liveness audit found the whole capabilities block has no reader at all. The engine gates pushdown on the runtime driver's own supports.* object, a different mechanism with a non-overlapping vocabulary. Every key in the block is dead in liveness/datasource.json, and capabilities.readOnly is the one to know about: it reads as a safety switch and gates nothing — external.allowWrites: false is the enforced write gate.

One clarification, since these flips are easy to over-read: making a schema strict does not change its published JSON Schema. build-schemas.ts converts with io: 'output', and in output mode zod emits additionalProperties: false for a .strip() object too — the post-parse shape genuinely has no extra keys. So the JSON Schema was already advertising additionalProperties: false while the zod parse quietly accepted and discarded unknown keys. These flips align the parse with the contract that was already published; they do not widen it.

Every rejection is written to be self-fixing: it names the offending key and, where recognisable, the canonical spelling (stepsnodes, edge from/tosource/target, readallowRead, tabstabPermissions), a wrong-layer pointer (a flow-level objectName belongs on the start node's config; apiOperations is response-side only), or a retired-key tombstone (contextVariables → ADR-0105 D11's rlsMembership resolvers; isProfileisDefault, ADR-0090 D2).

Migration is by construction behavior-preserving: any key now rejected was previously stripped, so it never had a runtime effect — remove it or rename it to the canonical key the error suggests; nothing about a working app changes.

The gate paid for itself immediately by exposing keys the platform writes but the spec could not express, so PermissionSetSchema gains three: the description shown in Setup (persisted to sys_permission_set.description), the author-facing protection block, and the ADR-0010 runtime protection envelope (_lock, _packageId, _provenance, …) that every sibling metadata type already carried — without it, a package-owned permission set could not round-trip through an environment overlay.

agent.tools[] is removed — capability comes from skills (ADR-0109, #3820)

ADR-0064's invariant is "an agent's tool set is the union of its surface-compatible skills' tools; nothing falls through to the global registry". The inline tools slot was the one seam that broke it — the runtime resolved agent.tools[].name against the full registry with no surface check, so an ask-surface agent could name an authoring tool and get it. AIToolSchema and the AITool type go with it.

Migration: attach capability through skills. An agent authoring tools is not a parse error — the key is tombstoned and stripped — so existing stacks keep parsing, but the slot no longer does anything.

Two lint changes ride along: validate-ai-tool-references now models AI exposure the way the runtime does (a tool materialises only when the action declares ai.exposed: true + ai.description and has a headless path — url/modal/form are UI-only), and a new validate-ai-agent-authoring rule warns on a stack declaring stack.agents, which the runtime has filtered from the catalog since ADR-0063.

Error codes are one SCREAMING_SNAKE vocabulary, and error.code is a closed set (ADR-0112, #3841)

The platform shipped two live error-code dialects: StandardErrorCode declared lowercase snake_case members while 139 codes on the wire were SCREAMING_SNAKE, and ApiErrorSchema.code was a bare z.string(), so nothing validated either. Now there is one vocabulary — the standard catalog plus ERROR_CODE_LEDGER for service-specific codes — and error.code validates against their union, so an unregistered code fails parse instead of quietly becoming a new dialect.

Wire-visible. Codes change spelling on every surface that spoke lowercase. Generic conditions collapse onto the standard catalog rather than keeping a synonym: unauthorized/unauthenticatedUNAUTHENTICATED, forbiddenPERMISSION_DENIED, not_foundRESOURCE_NOT_FOUND, internalINTERNAL_ERROR, unavailableSERVICE_UNAVAILABLE, not_supportedNOT_IMPLEMENTED, bad_requestINVALID_REQUEST (a status name is not a semantic code). Domain conditions get a registered SCREAMING code.

Migration: branch on error.code values, and do not pattern-match their case. A code branch that misses fails silently — nothing throws, the affordance it guards just disappears — which is how eleven console branches broke without a single test failing (objectui#2977). If you support servers on both sides of the upgrade, compare case-insensitively; that is what the console does.

Four routes also stop putting a code in the message slot: the webhook redeliver route, the API-trigger webhook and two rest routes answered { success: false, error: '<code>', message }. They now emit error: { code, message }, so a client reading body.error as a string on those routes must read body.error.code.

Not swept, deliberately: sys_metadata_audit.code (persisted audit history, and the column also holds non-errors like ok), diagnostics records that ship inside a 200, field-level codes (see below), and the CLI's --json output contract.

Field-level error codes are their own closed catalog, and fieldErrors becomes fields (ADR-0114, #3977)

The per-field code inside fields[] is a different vocabulary from error.code, and it stays lowercase on purpose: a top-level code names the condition the request hit, while a field-level code names the constraint the value violated — and constraints are declared in the metadata's own snake_case, so max_length the code and max_length: 50 the property are the same word. FieldErrorCode closes that set (27 members) and FieldErrorSchema.code validates against it, where it used to be z.string().

EnhancedApiError.fieldErrors is renamed to fields — the name every producer already emitted. The old key was declared and emitted by nobody, so a reader keying on it was reading a field no server sent. It is tombstoned rather than deleted: writing it now fails with the rename instead of parsing clean and losing the array.

Wire-visible. Routes that validate with Zod stopped leaking Zod's issue codes: too_small now becomes min_length / min_value / min_items depending on what was too small, unrecognized_keys becomes unknown_field, and — the one that was a real bug — a missing required property now reports required instead of the invalid_type Zod uses for it, so a form marks it as missing rather than wrong-typed.

Migration: read error.fields; branch on the catalog's lowercase codes for per-field handling, and show message to the user rather than the code.

Approval requests are visible to participants, not the whole tenant (#3590)

getRequest / listRequests / countRequests query with SYSTEM_CTX to bypass RLS because approver visibility spans identity forms RLS cannot model — but only the tenant half of that rule was ever written. Any authenticated user could read any approval request in their tenant, including its payload snapshot, its full decision history and its attachments. approverId on listRequests is a filter, not authorization: omitting it returned the whole tenant. Callers that relied on the wide read must now be participants (or hold the admin override).

Sharing: the full access level is gone, and the recipient enum matches the runtime (#3865, #1878)

  • accessLevel: 'full' is removed. It advertised delete/transfer/share and granted plain edit. Stored rules convert to 'edit' — the access they actually had — through a protocol-17 conversion, so nothing silently gains or loses reach.
  • sharedWith.type: 'group''team' (wire rename), matching the ADR-0090 D3 vocabulary the runtime already expands via sys_team / sys_team_member. The old spelling was silently skipped at seed time.
  • business_unit is added to the authoring enum — exactly one business unit's members, no subtree (use unit_and_subordinates for the subtree). The runtime already enforced it; only the enum omitted it.
  • guest and the owner-type rules are pruned. Every authorable recipient and rule type is now enforced.

Sharing rules: an empty criteria shares nothing (#3896)

A rule stored without criteria — missing, null, {}, unparsable, or a misspelled key (criterias) — used to evaluate as find(object, { filter: {} }) under the system context: every record of the object, granted to the recipient, up to the evaluator's 5000-record window. Three write paths accepted that shape without validation: POST /api/v1/sharing/rules, a direct sys_sharing_rule insert (what authoring in Setup issues), and the seed bootstrap's own match-all branch. The field description even advertised it — "leave empty to share every record" — which was never a feature, only a name for the bug.

Now, in three layers:

  • defineRule rejects a match-all criteria with VALIDATION_FAILED.
  • The evaluator matches nothing for such a rule and logs why. This is the layer that matters for already-deployed tenants: a row stored before this gate under-shares instead of over-sharing, and the next reconcile revokes the grants it had materialised. No data migration is needed.
  • A sys_sharing_rule insert fails field-level (fields[].field = 'criteria_json'), so Setup marks the Criteria input instead of saving a rule that silently does nothing. The Console builder says so before you save (objectui#2962) and renders the server's reason on the field (objectui#2966).

There is no "share every record" sharing rule: object-wide read is the object's organization-wide default (sharingModel). A rule that relied on the empty shape must state its predicate, or the object should use sharingModel.

RLS: enabled is enforced, priority is removed (#3980, #3990)

  • enabled: false now actually disables a policy. The schema always said "Disabled policies are not evaluated", but nothing read the property — and because applicable policies OR-combine (any match allows access), a policy an admin switched off kept granting row access. The gate is at the single choke point both the find and analytics paths flow through; enabled absent stays active, so no stored policy changes behaviour. Access-narrowing only — but re-check any policy you set enabled: false on expecting no effect, because until now it had none.
  • priority is removed. It promised "conflict resolution" that cannot exist: with OR-combination there is never a conflict to order, and evaluation order cannot change an outcome. Nothing ever read it. Authored values are tombstoned with a fix-it prescription; os migrate meta deletes the key mechanically, and policy outcomes are identical with or without it.

required is a write contract; the column constraint is storage.notNull (ADR-0113)

field.required used to mean three things through one knob: the write check, the physical NOT NULL, and the drift expectation. Bound together, tightening any invariant on a deployed object was a destructive migration blocked by the very legacy nulls that motivated it — so real invariants ended up as imperative guards (three of them for sys_sharing_rule.criteria_json) or didn't happen. Split in v17:

  • required: true is the write-time contract, uniformly: an insert must provide a non-null value, an update may not null it out — a PATCH clearing a required field is now rejected (it silently passed before) — and legacy null rows rest: a write that doesn't touch the field never blocks. The column stays nullable.
  • storage: { notNull: true } is the explicit physical constraint. It owns the NOT NULL DDL and the destructive-migration ceremony (backfill first). Declaring it at field creation is free; declaring it over existing nulls is loud.
  • requiredWhen inherits the same non-regression rule: a write that flips the condition true without providing the field is rejected (it creates the violation), while a row violating since before the rule tightened no longer locks out unrelated edits. storage.notNull × requiredWhen is rejected at parse — a conditional contract cannot be an unconditional constraint.
  • Pre-17 sources keep their exact meaning: os migrate meta stamps storage: { notNull: true } onto every previously-required field (field-required-notnull-explicit) — under the old semantics that column was created NOT NULL, so the rewrite writes down what the text already meant. Migration-chain-only: the loader never guesses.
  • Drift: nullability now compares against storage.notNull. A column stricter than its declaration is needs_confirm (ratify or relax — dev auto-reconcile no longer silently strips a stray NOT NULL), and silent when the field is required (the write gate makes the constraint unreachable).

Membership grade is not a capability channel (ADR-0108, #3723)

sys_member.role answers "what is your standing in this organization", not "what may you do" — but resolve-authz-context projected every value stored there into current_user.positions, so business capability handed out through the membership grade was capability — granted with none of the position system's controls (no granted_by, no effective dating, no ADR-0091 checks). The vocabulary is now closed. Move business capability to positions (sys_user_position).

better-auth 1.7.0-rc.2 — account identity restructuring

better-auth renamed account.accountId to account.providerAccountId and added a required account.issuer; sign-in now resolves accounts by (issuer, providerAccountId).

  • FROM fields: { accountId: 'account_id' } → TO fields: { issuer: 'issuer', providerAccountId: 'account_id' }. The provider account id keeps its account_id column; sys_account gains an issuer column.
  • FROM internalAdapter.createAccount({ providerId, accountId, … }) → TO createAccount({ providerId, issuer, providerAccountId, … }). A local password account carries better-auth's own local:credential issuer.
  • FROM client.auth.accounts.unlink({ providerId, accountId }) → TO unlink({ accountId }), where accountId is the account row id from accounts.list(). That listing now returns issuer + providerAccountId in place of accountId.

Existing deployments: rows written before 1.7 have no issuer and are invisible to sign-in until stamped. The auth plugin runs an idempotent boot-time backfill that derives what it can — local:credential for password accounts, local:oauth:<providerId> for configured social providers, and the registered IdP's real iss from sys_sso_provider for federated ones. Accounts from a federated IdP that is no longer registered cannot be derived; they are logged with their provider id and row count rather than guessed, and those users cannot sign in through that provider until the row is stamped or removed so a fresh login re-links it.

@better-auth/scim deliberately stays on 1.7.0-rc.1 — rc.2 replaces its whole model, which is a feature migration rather than a version bump.

Dead SDK surface deleted (#3612, #3702, #3718, #3731)

Five client families built URLs that exist on no server surface, so every call was a guaranteed 404. They are removed, along with the four unconsumed DEFAULT_*_ROUTES tables that underwrote them:

  • client.permissions.* (check, getObjectPermissions, getEffectivePermissions)
  • client.realtime.*service-realtime registers zero HTTP routes
  • client.workflow.* (getConfig, getState, transition)
  • client.views.* CRUD — there is no /ui/views route anywhere
  • client.notifications device/preference helpers — the ADR-0012 server side was never built
  • client.ai.{nlq,suggest,insights} — the whole namespace, rebuilt below
  • client.projects.listTemplates() — targeted GET /api/v1/cloud/templates, mounted by nothing
  • os environments create --template and its template_id body field — the flag was accepted, transmitted and dropped with no seeding, no error and no stored trace

Console consumers: the companion objectui change trims the matching dead delegates from useClientNotifications (@object-ui/react), so a host calling registerDevice / unregisterDevice / getPreferences / updatePreferences through that hook loses them at the same time (objectui#2862).

Kept: client.events (explicitly a local in-memory buffer), the dispatcher-served notifications.list/markRead/markAllRead, approvals.*, and meta.getLegalNextStates. Re-adding any removed surface now requires the server route to exist and a route-ledger row proving it.

Two REST↔client mismatches are reconciled at the same time: marketplace publish moves from POST /api/v1/packages to POST /api/v1/packages/publish (the bare path collided with install semantics and REST wins first-match, so every packages.install call 400'd), and meta.getView stops speaking a ?type= query dialect only the dispatcher understood.

The ai namespace now expresses the AI surface that exists (#3718)

SDKRoute
ai.chat(request)POST /api/v1/ai/chat — forces stream: false
ai.chatStream(request)POST /api/v1/ai/chatAsyncIterable of UI Message Stream frames
ai.complete(request)POST /api/v1/ai/complete
ai.models()GET /api/v1/ai/models — the plan-filtered picker list
ai.conversations.*the six /api/v1/ai/conversations routes
ai.agents.*, ai.pendingActions.*the agent + pending-action routes

ai.chatStream returns a promise for an async iterable rather than being an async generator, so the request is issued — and an HTTP error thrown — when you call it, not when you first iterate.

service-ai is a Cloud/EE package: this repo proxies /api/v1/ai/** and, without it, answers 501 — the route is mounted, the implementation is not — so check discovery.services before calling. For a React chat UI, useChat() (@ai-sdk/react) remains the better client — these methods are for callers that are not components.

The spec's dead AI declarations retire with the namespace: Ai{Nlq,Suggest,Insights}{Request,Response}[Schema], DEFAULT_AI_ROUTES (getDefaultRouteRegistrations() returns 8 groups), and the AiProtocol interface. The real server contract is IAIService + IAIConversationService in @objectstack/spec/contracts.

The GraphQL surface is removed (#2462)

GraphQL was schema-only from day one: 20+ config schemas, a handleGraphQL that answered 501 unconditionally because kernel.graphql was never assigned, and three separate mounts advertising the dead endpoint. api/graphql.zod.ts and contracts/graphql-service.ts are deleted; graphql is removed from CoreServiceName, ApiProtocolType, the query-adapter dialects and the discovery/router route fields. /graphql now 404s (it used to 501).

Not removed: the 'graphql' protocol option on external datasource lookups — third-party systems may speak GraphQL, and that is not our API surface.

The ObjectStackProtocol composition alias is dissolved (ADR-0076 D9, #3606)

The transitional union of the twelve per-domain contracts — plus its parallel ObjectStackProtocolSchema Zod object and ObjectStackProtocolZod inferred type, 171 schema lines — is removed. Capability availability comes from the runtime discovery services registry; a static union was its degraded snapshot.

Migration: depend on the narrowest per-domain slice you actually use (DataProtocol, MetadataProtocol, …), composing them the way REST does (DataProtocol & MetadataProtocol). ObjectStackProtocolImplementation now declares exactly the four domains it provides — Data, Metadata, Analytics, Package — which the type system enforces. Breaking for importers of the alias or the Zod schema; no runtime behaviour change.

objectql's protocol re-exports are dropped in the same step: the protocol assembly is single-sourced through metadata-protocol.

A datasource that cannot connect fails the boot (#3741, #3758, #3826)

  • A declared datasource that objects bind to must connect. Previously only an external datasource with validation.onMismatch: 'fail' fail-fasted; everything else degraded to one warn line. An app declaring datasource: 'analytics' with 20 objects bound to it, booted against a wrong URL, started clean, exited zero, and then failed every read and write of those 20 objects with Datasource 'x' is not registered.
  • objectql.init() refuses to boot when a data driver fails to connect.
  • The standalone default datasource is now a declaration, connected through the one DatasourceConnectionService path instead of being pre-built and smuggled in as a driver.* kernel service with its own copy of the failure policy.
  • /ready reports 503 when a data driver stops answering, and a down datasource is visible in Setup with the operator-facing reason.
  • Connection attempts are bounded at 10s with an accurate error message.

If you relied on a boot that survived an unreachable non-default datasource, that boot was already broken — it just failed later and with a worse message.

unique materializes per tenant (#3696)

unique: true became a single-column global index that ignored tenancy entirely, while the autonumber sequence table is keyed by (object, tenant_id, field, scope) and hands every tenant its own counter starting at 1. Tenant B's PROD-00001 was rejected by an index it could not see — and the rejection doubled as a cross-tenant existence oracle. The index is now tenant-scoped, matching the sequence.

Aggregation result shapes (#3839, #3849)

  • One key for the empty group bucket. Both aggregation paths now emit a real null for the empty bucket instead of two different placeholder spellings.
  • A group key is the column's value, in the shape find() presents it. Grouping and reading no longer disagree about the representation of the same column — including SQLite Field.datetime, which used to collapse every row into one (null) bucket, and raw epoch storage that aggregate() / distinct() leaked.

i18n routes answer in the shapes they declare (#3676, #3778, #3847)

  • /i18n/labels/:object/:locale emits the declared entry object ({ label, help?, options? }) instead of a bare Record<string, string>. A client typed against GetFieldLabelsResponse read labels[field].label and got undefined; the SDK's type was right and the servers were wrong. help and options stop being discarded.
  • /i18n/locales answers in one shape, found by a new success-envelope conformance suite that parses every /i18n success body against the schema the route declares — rather than against a hand-written literal, which is what let three of these ship green.
  • The translation metadata type speaks objects.<object>, the shape every resolver, os i18n extract, the Console hooks and all nine shipped bundles already used. It had been registered against an object-first o.<object> schema, so a translation authored in the product saved successfully and then rendered nothing. Real-world footprint of the retired shape was zero — all three *.translation.ts files in the tree were already objects.-shaped — so this is a registration fix, not a migration.
  • GetTranslationsRequest is locale-only. The namespace / keys filters were declared, put on the query string by the SDK, and read by neither serving surface; passing keys shrank nothing and reported nothing.

The dispatcher's error.code is the semantic code, not the HTTP status (#3842)

Everything the runtime dispatcher serves — /meta/*, /actions/*, /packages/*, /automation/*, /analytics/*, /ready, the route-not-found 404 — used to answer with the HTTP status in error.code, the field ApiErrorSchema declares as a semantic string. The real code had to live somewhere else and did, in three different somewhere-elses: error.details.code, error.details.type, and a sibling error.type.

// before                                    // after
{ "error": {                                 { "error": {
    "message": "…",                              "code": "PERMISSION_DENIED",
    "code": 403,                                 "message": "…",
    "details": { "code": "PERMISSION_DENIED" }   "httpStatus": 403
} }                                          } }
  • error.code is the semantic string. error.httpStatus is the number. error.details is context only. Replace a body.error.details?.code ?? body.error.type read with body.error.code, and a body.error.code read (for the status) with body.error.httpStatus.
  • SDK callers need no change. ObjectStackClient already normalised this — err.code semantic, err.httpStatus numeric. The old-shape fallback read was retired later in this same window (#4007): SDK and server ship on one release train, and the ADR-0112 rename changed the code values a dug-out code would need to match.
  • No code was renamed. PERMISSION_DENIED, ROUTE_NOT_FOUND, PASSWORD_EXPIRED, PROJECT_MEMBERSHIP_REQUIRED, VALIDATION_FAILED and unauthenticated all reach the wire spelled exactly as before; only their field changed. Reconciling the platform's two code vocabularies is #3841. A branch with no code of its own now derives a StandardErrorCode from the status (403PERMISSION_DENIED, post-rename spelling), spelled in one map in the spec.
  • Spec: ApiErrorSchema gains optional httpStatus; StandardErrorCode gains method_not_allowed and precondition_required (both additive). DispatcherErrorCode changes members from '404' | '405' | '501' | '503' to the four semantic spellings the removed error.type declared, and DispatcherErrorResponseSchema.error.code becomes a string — it had declared the opposite of ApiErrorSchema for the same field, which is what let the deviation stand.

Anonymous access is denied unconditionally — api.requireAuth is gone (#3963)

api.requireAuth was a deployment-wide opt-out: one boolean that let a stack serve its entire data plane to unauthenticated callers. Auth is a kernel concern, not a deployment posture, so the key is retired and anonymous access to object data is now denied on every HTTP surface. A stack that mounts no auth at all fails at boot when it would serve a data API, instead of receiving an implicit fail-open.

Publish public surfaces by declaration instead — each derives its own narrow authorization rather than opening the whole data plane: a public form view (sharing.allowAnonymous), a share-link token (read as SYSTEM), or book.audience: 'public' (ADR-0046 §6.7). os migrate meta drops the key and emits a notice telling you where public access has to be re-declared.

wait never had a timeout — timeoutMs / onTimeout are retired (#4158)

Both keys described a timeout and neither delivered one. waitEventConfig.onTimeout had zero readers — no path ever inspected it, so neither fail nor continue ever happened, while its .default('fail') stamped a decision nothing made onto every wait node. waitEventConfig.timeoutMs said "maximum wait time before timeout", but its only reader used it as the timer duration when timerDuration was absent: it did something, just not what it claimed. A wait resumes when its timer elapses or its signal arrives — never on a deadline.

os migrate meta converts timeoutMstimerDuration (stringified — a bare numeric string reads as milliseconds, so the wait is unchanged) and drops it outright when timerDuration was already set. onTimeout is deleted; there is no replacement. Real timeout semantics are left to be built to a requirement rather than retrofitted onto two keys that happened to be declared.

The query request surface sheds five inert keys (#4196, #4286)

QueryAST is a request shape — never stored in stack metadata — so these are caller-side edits, not a source rewrite: os migrate meta has nothing to convert. Each key now fails to parse with its prescription, and authoring one is a tsc error at the call site.

RemovedWhyUse instead
fields[] object form { field, fields, alias }inert end to end — every reader treats the list as string[], so it was dropped by the SQL and memory drivers, projected as a column named "[object Object]" by MongoDB, and refused by the REST ingressexpand, or a dotted path for a single related column (fields: ['owner.name'])
query.joinsno engine or driver ever read it; the name squatted on the reserved REST parameter set (the JoinNode/JoinType/JoinStrategy cluster goes with it)expand — the engine resolves it via batch $in queries
query.windowFunctionsfind() never applied one, so every OVER clause was silently dropped; WindowFunctionNode declared field/over/frame members the live door never readaggregations + groupBy; embedders on a SQL datasource call SqlDriver.findWithWindowFunctions()
query.cursorno driver implemented keyset pagination — the cursor was accepted and ignored, so every page came back identical and a caller looping "until hasMore is false" never terminatesa where predicate on your sort key ({ created_at: { $gt: last.created_at } }) with the matching orderBy
query.distinctmis-wired, not merely dead: no driver rendered SELECT DISTINCT, and its only observable effect was suppressing the REST list count — callers got duplicate rows and a degraded total/hasMoregroupBy, the count_distinct aggregation, or the drivers' distinct(object, field) door

QueryBuilder.cursor() and QueryBuilder.distinct() are deleted with their keys. The count suppression goes too, so total is truthful again for queries that used to send distinct.

BatchOptions.validateOnly (#4052) is retired on the same grounds: it promised a dry-run ("validate records without persisting") that no batch surface ever honoured — updateManyData, deleteManyData and batchData persisted regardless, so a caller sending it to preview a mutation got it executed. It is HTTP-only; stop sending it.

findOne must say which record it wants (#4419)

findOne reads a single row. That makes its predicate the only thing standing between the caller and an arbitrary record — and when the predicate is missing the result is not null, it is the object's first row: a real, plausible-looking record with nothing to do with the request, which the if (!row) check every call site already has cannot catch, and which then propagates into whatever is computed next. Downstream of one such call, line items defaulted their price from the first product in the catalog, and "is this deal already closed?" was answered against an unrelated record while the write that followed correctly targeted the intended id.

So a query that selects nothing in particular is now refused rather than answered. Say which record you want in one of three ways:

Instead ofWriteMeaning
findOne(o) / findOne(o, {}) / findOne(o, { where: {} })findOne(o, { where: … })the record matching this predicate
findOne(o, { search: 'Acme' })the record this search finds
findOne(o, { orderBy: [{ field: 'created_at', order: 'desc' }] })the FIRST record in this order — the newest
find(o, { limit: 1 })any row will genuinely do — and the call site says so

The error names all four. find and count are unchanged: returning or counting every row is an honest answer, and only findOne's implicit "just one of them" turns a missing predicate into a confidently wrong record.

Two silent drops that produced the same wrong record are fixed with it:

  • findOne({ search }) now applies the search. The ADR-0061 search → cross-field $contains expansion ran in find only, while both methods are checked against the same legal-key set — so search passed the gate, reached a driver that does not read it, and the read came back unpredicated. The expansion is now one function both call, and a drift pin requires every option findOne declares to have an observable effect.
  • MongoDBDriver.findOne now applies orderBy, fields and offset. It translated where and dropped the rest, so "the newest record" returned whichever document the scan reached first. No ordering is imposed when the caller supplies none (#4363) — that part is unchanged, on both drivers.

Together with the filterwhere fold on every entry point and the unknown-key rejection (both already in this release), a read parameter the engine does not execute now fails at the call site instead of quietly changing the answer.

Dead spec clusters removed

App shell (2026-06 liveness audit, #4001 app step). App.version, App.aria, App.objects, App.apis, App.sharing, App.embed and App.mobileNavigation are tombstoned — none was ever read by framework or objectui. sharing/embed were the dangerous pair: a declared public-access surface no route enforced (the live path is FormView.sharing). mobileNavigation was a mode picker that changed nothing. Each key rejects with its prescription; os migrate meta deletes them from your source.

Each of these parsed and did nothing. None has a runtime consumer; delete the import or the authored key.

RemovedNote
PortalSchemaportal metadata was never enforced
AuditConfig cluster (@objectstack/spec/system)dead since #1878
Capabilities-descriptor cluster (ObjectQL/ObjectUI/Kernel/ObjectStack/ObjectOS CapabilitiesSchema)static snapshots superseded by runtime discovery
FeatureFlagSchema (kernel feature.zod)orphaned module
DevPluginConfigSchema cluster (kernel dev-plugin.zod — DevServiceOverride / DevFixtureConfig / DevToolsConfig / DevPluginPreset)a declared dev-mode protocol (presets, fixtures, dev-tools dashboard, per-service mock/stub strategies, simulated latency) nothing implemented and no load path parsed — @objectstack/plugin-dev reads its own DevPluginOptions, and the strategy: 'stub' vocabulary described the dev-stub design ADR-0115 retired (#4149)
SkillSchema.permissionsnever gated anything (#3686)
tool.requiresConfirmationa safety flag nothing enforced (#3715)
object.enable.trash / enable.mruADR-0049 enforce-or-remove close-out (#2377)
ReportColumnSchema / ReportGroupingSchema + report chart groupByunread (#3463)
Report aria / performance propsreport-liveness close-out
DataQualityRulesSchema / ComputedFieldCacheSchemaorphaned value schemas (#3726, #3733)
DynamicLoadingConfig / PluginDiscoveryConfig / PluginDiscoverySourcepromised plugin sandboxing / integrity verification / source allow-listing / load approval; none of it was ever wired (#3950)
RowLevelSecurityPolicy.priorityconflict-resolution semantics that cannot exist under OR-combination (#3990)
tool.category / .permissions / .active / .builtIn (+ ToolCategorySchema)authorable and inert — permissions gated nothing, active: false withdrew nothing; strict-rejected with prescriptions (#3896 close-out)
action.shortcut / .bulkEnabledno keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions (#3896 close-out)
flow.active / .template / node outputSchema / errorHandling.fallbackNodeIdactive: false never stopped a flow — status is the enforced lifecycle; faults route via per-node fault edges (#3896 close-out)
view: list responsive/performance, form defaultSort/ariano renderer read any of them; list aria/data and form data stay live — defineForm writes data: { provider: 'schema', schemaId } onto every metadata form (#3896 close-out)
dashboard.aria / .performance / widget performance (+ PerformanceConfigSchema)no renderer applied them; virtual scrolling is the live top-level virtualScroll (#3896 close-out)
agent.knowledge (+ AIKnowledgeSchema)declaring sources/indexes never scoped retrieval — search_knowledge takes sourceIds from the LLM's tool-call arguments (#3896 close-out)
PluginLifecycleSchema (onInstall/onEnable/onDisable/onUninstall/onUpgrade) + UpgradeContextSchema + the ObjectStackPlugin interface family (@objectstack/spec/system)a plugin lifecycle the kernel never implemented — the real contract is init/start/destroy; code written against the hooks silently never ran (#4212)
The typed-event cluster (kernel plugin-lifecycle-events.zod — ten payload schemas (PluginRegisteredEvent, HookTriggeredEvent, KernelReadyEvent, …), the 21-name PluginLifecycleEventType enum, ITypedEventEmitter)a typed-event system that was never built: zero consumers, and the enum was wrong in both directions — 17 names nothing fires, 10 real events missing. IPluginLifecycleEvents is now the registry of the 14 events with a real emitter, and the new LifecycleEventName union soft-types PluginContext.hook/trigger (#4212 follow-up, #4241)
skill.triggerPhrasesphrases were never matched; activation is triggerConditions + the agent's skills[] allowlist (#3896 close-out)
DEFAULT_DISPATCHER_ROUTESdead route table
Aspirational config on Theme / Translation / Webhookstill-dead after #3494
ChartInteraction.zoom / .clickActionnever implemented (#3752)
The workflow service slot — CoreServiceName 'workflow', IWorkflowService, WorkflowProtocol, the Get/WorkflowState/Config/Transition schema cluster, discovery routes.workflow / services.workflow / features.workflow, the RestApiRouteCategory 'workflow' member and the stray graphql provider entrydeclared end to end and implemented nowhere: nothing ever registered or resolved the slot (ADR-0115 Evidence 5), no method of WorkflowProtocol was ever implemented, no host ever mounted /api/v1/workflow. State machines are state_machine validation rules; approvals are flow nodes (ADR-0019); record-triggered automation is hooks + record_change flows (#4451)
datasource.readReplicasreplica connections nothing ever opened — no driver reads the key and no query path splits reads from writes, so every statement went to the primary. #4410 had just taught the schema to validate each entry against the declared driver's contract, which made a dead slot look rigorously alive (#4468)
The per-provider connector "template" cluster (@objectstack/spec/integrationDatabaseConnectorSchema, FileStorageConnectorSchema, GitHubConnectorSchema, MessageQueueConnectorSchema, SaasConnectorSchema, VercelConnectorSchema, their ~100 sub-schema/type/example exports, and the six generated reference pages)the losing side of a decided architecture fight, left standing: ADR-0023 rejected hand-modelling each external system's shape inside the spec, and the live ADR-0097 protocol gets provider shapes from the provider itself (connector-openapi / connector-mcp materialize at boot). Zero consumers — engine.registerConnector() validates against ConnectorSchema from connector.zod.ts alone, and nothing referenced the six files, not even their own module's live half. DatabaseConnectorSchema also declared read-replica routing a second time (readReplicaConfig, see the row above), down to a weight field for a load balancer that does not exist (#4480)
The trigger-registry.zod.ts Connector cluster (@objectstack/spec/automationConnectorSchema, ConnectorInstanceSchema, ConnectorOperationSchema, ConnectorTriggerSchema, the Authentication*/OAuth2Config/Operation* vocabulary, the Connector.apiKey()/.oauth2() factory helpers, and the generated reference page)the third declaration of the same business need, and the file never contained what its name promises — no trigger registry, 630 lines of connector vocabulary with zero consumers. The automation engine registers connectors against integration/connector.zod.ts (ADR-0097) and the stack connectors: collection parses DeclarativeConnectorEntrySchema; nothing registered, validated or executed against this copy. Its header even carried a "When to use" comparison steering lightweight cases here — a signpost to a dead end, removed with it (#4499)
The kernel metadata-loader envelope family — MetadataFormat, MetadataStats, MetadataLoadOptions, MetadataSaveOptions, MetadataExportOptions, MetadataImportOptions, MetadataLoadResult, MetadataSaveResult, MetadataWatchEvent, MetadataCollectionInfo, MetadataLoaderContract (@objectstack/spec/kernel)eleven names that each existed twice, with a different shape, on ./kernel and ./system — so which type you got depended on your import path. Every consumer imported the ./system copy; the ./kernel copies had zero consumers. Import them from @objectstack/spec/system (#4411, ADR-0049). MetadataManagerConfig / MetadataFallbackStrategy are unaffected and still ship from both entries

The Console side follows: @object-ui/types drops its ObjectStack/ObjectOS/ObjectQL/ObjectUI Capabilities re-exports, which pointed at the same retired cluster (objectui#2860). Import what you still need from @objectstack/spec directly.

Smaller breaking changes

  • MongoDB driver declares itself single-tenant and refuses to boot in a multi-tenant configuration rather than silently mixing tenants (#3724).
  • Multi-organization operation is an entitlement again. The group posture no longer self-activates — it requires the enterprise runtime (@objectstack/organizations). The first ADR-0105 wave made it self-activating, which turned group into a free multi-org path around the isolated gate and made the weaker isolation the free one.
  • bootStack({ multiTenant: true }) now requests the isolated posture explicitly.
  • Kernel-built assignment notifications are dropped from plugin-audit; the policy moves to user-space automation (#3403).
  • sys_view_definition's all-six apiMethods whitelist is dropped (#3026).
  • os migrate plan shows index drift — index DDL is no longer applied silently at boot (#3728).
  • A flow's errorHandling.strategy: 'retry' must state maxRetries (>= 1) (#4247). maxRetries had two defaults — .default(0) in FlowSchema and ?? 3 in the engine's retryExecution — so an unstated count retried 0 times for a flow that had been through the schema and 3 times for a definition handed to the engine directly. The engine's copy is gone (it reads the parsed block with no fallback), and the case that was ambiguous is rejected rather than guessed: retrying zero times is strategy: 'fail' under another name, and a retry re-runs the whole flow, so the count is the author's to state. Fix: write { strategy: 'retry', maxRetries: 3 }, or strategy: 'fail' if no retry was intended. maxRetries: 0 stays legal under 'fail' / 'continue', which never read it.
  • ObjectQLEngine.use() and ObjectQLHostContext are removed (#4212 follow-up, #4242). The engine's own plugin loader — register a manifest part, then dispatch the runtime part's onEnable over an ObjectQLHostContext — had zero callers repo-wide, and its onEnable was the engine-level twin of the #4212 disease: a lifecycle entry point that reads as a contract and never runs. Fix: engine.use(manifest)engine.registerApp(manifest); engine.use(_, { onEnable }) → a kernel plugin (kernel.use({ name, init(ctx) { … } }), engine via ctx.getService('objectql'), drivers via engine.registerDriver()). new ObjectQL({ logger }) and ObjectQLPlugin's hostContext option keep working, and the app-bundle onEnable module export (dispatched by AppPlugin at boot) is a different, real contract — unchanged.

New capabilities in 17.0.0

Files become platform records (ADR-0104)

The headline of the line. @objectstack/spec/data now owns the runtime value shape of every field type (field-value.zod.ts): semantic type classes, isMultiValueField, and valueSchemaFor(field, 'stored' | 'expanded'). The four consumers that each hand-copied this knowledge — the objectql record validator, REST import coercion, driver-sql column classification, and the QA conformance matrix — derive from the spec instead, and the field-zoo round-trip matrix is asserted against the contract so they cannot drift.

For media fields specifically:

  • The stored form narrows to an opaque sys_file id. The inline {url, name, size, …} blob becomes the 'expanded' read form, which still admits an unresolved id exactly as an unexpanded lookup id stays valid.
  • accept and maxSize are declarable on FieldSchema and enforced on the server. Both were already read by the upload widgets while the spec did not declare them, so authoring them meant a silently stripped key and a constraint that never existed. Because the platform now owns the file, sys_file carries the authoritative MIME type and byte size, so a record write is re-checked where it binds — a browser-side check is a convenience, not a control. Violations raise FileConstraintError. An entry is judged only against metadata the file actually reports: "we don't know" never becomes "not permitted".
  • Exclusive field-reference ownership, a governed download path for field-owned files, and an object's ability to delegate file-read authorization to its service replace the unguessable-URL model. Downloads carry the real filename and content type instead of the URL token, and _local/file/:key — a URL nothing mounted — is gone.
  • Two legacy forms stop conforming, deliberately: the inline blob (no longer stored, now derived) and the external URL (never a managed file — it retires toward an explicit url field, so "managed file" and "external link" stop being the same declaration).

Value-shape checking follows your own migration, not the version number. A not-yet-backfilled row still writes, and the author gets a warning naming the field. Media fields start rejecting malformed values only once this deployment has run the migration and passed its self-check:

os migrate files-to-references           # dry run: reports, writes nothing
os migrate files-to-references --apply   # converts, verifies, records the flag

The run backfills legacy file-field values (inline metadata blobs, own-resolver URLs, data: URIs) into owned sys_file references and reconciles the ownership ledger against what records actually hold. The deployment-level flag it records — never the platform version — is what authorises both strict media value shapes and irreversible file collection. Upgrading changes neither; running the migration does.

The non-media classes have their own gate (#3438) — references (lookup, master_detail, user, tree) and structured JSON (location, address, composite, repeater, record, vector):

os migrate value-shapes           # scan: reports, writes nothing
os migrate value-shapes --apply   # scan, then record the flag if clean

A separate flag because it attests a separate fact: the file migration says file values were converted and their ownership reconciled, which tells you nothing about whether a lookup id or a location payload is well formed. This one converts nothing — a malformed location is application data only its author can correct — so it reports the object, field, type, count, sample record ids and parse issue, and you re-run until it is clean. OS_ALLOW_LAX_VALUE_SHAPES=1 re-opens leniency while diagnosing.

You are told about both, rather than having to find this page. os migrate meta — the command a 16→17 upgrade already runs — ends by naming the two data migrations and the gate each records, including on a run that rewrote nothing, since canonical metadata says nothing about stored values. A deployment that boots holding covered fields with no verified gate row logs one line naming the command that ends warn mode. Neither reads as "done": until a gate is recorded, the classes it covers keep warning.

That boot line is scoped to the deployments it can actually help. It counts only fields a write would check — not the lookups the registry injects into every object — and says nothing once an environment switch has already settled the posture, since OS_DATA_VALUE_SHAPE_STRICT_ENABLED enforces both classes outright and either OS_ALLOW_LAX_* opt-out is a deliberate choice the scan would not change.

A database created by 17 attests both flags at creation (#3438), so a new deployment enforces from its first boot instead of waiting for someone to run a migration that, for an empty store, does nothing. The platform attests only a store it watched itself create — every table made by that boot, none found already there; an upgraded or restored database attests nothing and produces its evidence by running the command.

Released-file collection is live behind that same flag (#3459). On a verified deployment, a field file whose one owning record lets go — the field cleared, or the record deleted — is tombstoned into the declared 30-day grace window; re-referencing the id inside the window revives it, and past it the platform sweep re-verifies at delete time that nothing holds the file (join rows, ownership columns, and a fresh read of the flag itself) before reclaiming the row and its bytes. A deployment that never migrates keeps every released file forever: upgrading is not consent — passing your own migration's self-check is.

Two knobs sit either side of that flag on the value-shape half. OS_ALLOW_LAX_MEDIA_VALUES=1 returns a verified deployment to warnings, for an operator who hits an unforeseen rejection and needs writes flowing while they diagnose. OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1 goes the other way and opts every value class into strict immediately — including the reference (lookup/user/…) and structured-JSON (location/address/…) types, whose own per-deployment gate is still being built (#3438). Those stay warn-only by default until it lands, because unlike media they have no migration standing behind them yet.

Approvals: dynamic approver routing (#3447)

  • expression approvers. A CEL expression resolves who approves at node entry, over exactly three roots: current.* (the record's live state), trigger.* (the submit-time snapshot) and vars.* (flow variables, including upstream node outputs). Bare record and bare field names are rejected before evaluation — on this platform record always means "the record at event time", which is ambiguous at an approval node — with error messages that prescribe the correct spelling. Optional resolveAs: 'user' | 'department' | 'position' | 'team' re-expands each resolved id through the same graph lookups the static types use; with behavior: 'per_group' each intermediate value forms its own sign-off group.
  • onEmptyApprovers policy, node-level, for every approver type: admin_rescue (default — the request opens for privileged takeover), fail, or auto_approve (skip the request and continue down the approve edge with output.autoApproved = true).
  • Decision outputs. The author declares allowed keys on the node (decisionOutputs); approvers fill values only; accepted outputs resume the run as <nodeId>.<key> variables, so a later node's expression can read vars.<nodeId>.picked_departments. Undeclared keys reject the decision; decision and requestId are reserved. A decisionOutputs entry may be typed ({ key, label?, type: 'text'|'user'|'department'|'position'|'team', multiple? }) to make the decision UI render a record picker instead of free text.
  • field / manager approvers resolve against the record's live state at node entry rather than the trigger snapshot the flow froze at submit time, so an earlier step can write the field that routes a later step's approvers. Graph approvers already resolved live; this brings the in-record types into line.
  • Approver value bindings are declared. APPROVER_VALUE_BINDINGS is the single declaration of how a designer sources each approver row's value. queue is deprecated for authoring — it still parses so stored flows keep loading, but it is published in xEnumDeprecated because the runtime has no queue resolution and the slot routed to nobody.
  • Also: cross-organization approver targeting, department approvers resolving against env-wide business units, per-group membership of pending approvers, inbox rows enriched with snapshot field labels (payload_labels), the pending node's lockRecord policy exposed on the request row, decision attachments returned as real file values, an admin override for requests routed to an unstaffed approver, a status mirror that names the human who caused the transition, and a decision recorded against the authenticated caller rather than a body field.

The SDK reaches the whole REST surface (#3563, #3587)

Beyond the deletions above, the client gains typed access to everything the server actually mounts: the actions surface, keys / shareLinks / security, the eleven package-lifecycle methods, metadata drafts/published/FSM, automation descriptors, the reports family, approvals and record shares, sharing rules, security-explain, and search. automation.resume() / automation.getScreen() finish a paused screen flow from the SDK.

The ledger is the point: a route-ledger conformance guard runs in both directions — every SDK URL must match a route some surface mounts, and every mounted route must be reachable or explicitly ledgered — with the REST surface, the dispatcher and the autonomously-mounted service routes each carrying their own ledger. analytics.meta / analytics.explain and two i18n calls that reached nothing are repaired by the same audit.

Write observability — a silent strip stops reading as a clean save

PATCH/POST /data surface droppedFields when the server silently strips a write, extended to the bulk paths, the cross-object batch, and the client SDK; flows surface silently-stripped write fields as step warnings; and a batch create now goes through the same create ingress as a single create. os validate runs the four authoring lints os build runs, so "validate clean, build fails" is gone.

Analytics correctness

  • ObjectQLStrategy enforces the read scope (RLS + tenant), and the read-scope auto-bridge no longer depends on plugin order.
  • timeDimensions[].dateRange is applied — the predicate every date-bucketed chart was missing.
  • The effective date granularity drives bucket labels and drill ranges, and widget dateGranularity / sortBy / sortOrder / limit are honoured in the dataset query.
  • Cross-object grouping is served in-envelope on the ObjectQL path by FK-expand, and fails closed when the path cannot join.
  • Dimension-label lookups are scoped to the referenced object's RLS, and dataset selections sort by display label for select/lookup dimensions.
  • Cube auto-inference is gated on object existence, and the dispatcher boundary stops returning raw SQL.

Automation & flows

  • Every terminal run reports what it did (#4354): selected / acted / skipped totals, a per-node breakdown, and which gate closed — on the run result, in listRuns / getRun, as one greppable log line (selected=30 acted=0 skipped=30 gate=check_stalled->send_nudge:30), and as queryable selected_count / acted_count / skipped_count columns on sys_automation_run. success: true never meant "it did its job": a sweep that selected thirty records and wrote none was indistinguishable from one with nothing to do, which is how three inert production flows ran green for as long as they had existed. selected > 0 && acted == 0 over consecutive runs is now the detector. See Run summaries.
  • record-after-write fires one flow on create or update (#3427), with previous bound as null on the create leg so start conditions can discriminate.
  • Opt-in single-hop lookup expansion for record-change flow templates.
  • The resume gate is one chokepoint: it follows map: too, is gated by the node the run is parked on, and the route stops accepting engine-internal variables.
  • A fault edge must not switch off a guardrail; refuse-to-execute guards lose their default-routable footgun; a filter that loses a condition must not run; array-form triggerType fails loudly instead of silently never firing; string templates serialize object tokens readably instead of [object Object].
  • retryPolicy / timeout authored on a job are honoured by the scheduler.
  • {filter-token} placeholders evaluate server-side, and the {current_user_id} vocabulary is frozen with unresolvable placeholders failing the build.

Historical data import

treatAsHistorical skips the state machine for historical-data migration and preserves the original audit timeline — including on undo. Row errors are sanitized so a constraint failure reads as human wording instead of leaking raw SQL.

Lint & CLI

New and widened rules: reference-integrity validation for object and action names, translation-bundle reference integrity and option-key validation, never-firing record trigger tokens, flow update_record writes to readonly fields, replay-unsafe mode: 'insert' seed datasets, seed values outside a declared state machine, label: 'error' written where type: 'fault' was meant, AI surface affinity (skill ↔ agent), the ADR-0109 platform tool-name registry with an advisory skill.tools[] reference lint, and expression/empty-slate/ reserved-output-key gates for the new approver capabilities. Filter references and flow template paths that cannot resolve now fail the build, not the run.

On the CLI: os i18n extract --check fails instead of writing when bundles have drifted, --json truncation is fixed across every command, the startup banner reads a DSN-declared datasource and stops printing credentials, and the boot banner reports seed outcomes (Seeds: X inserted · Y updated · Z skipped, escalating to a yellow ⚠ … N REJECTED line) so a fixture can no longer lose most of its rows in silence.

Spec, kernel & platform

  • ISecurityService is published — the security service surface becomes an enforced contract, with security.getReadableFields for export column projection.
  • API-method derivation is single-sourced: the server is the only adjudicator, and the exposure gate's metadata fail-open is observable.
  • The HTTP dispatcher is decomposed into per-domain modules behind a thin handler registry (ADR-0076 D11) — auth, ai, automation, packages, share-links, keys, storage, ui, actions, mcp, meta, data — and request→environment resolution unifies on the host's kernel-resolver seam.
  • An action rejects a body on a non-script action and rejects unknown keys on an action param instead of stripping them; an inline lookup param can declare its reference target.
  • ListColumn gains prefix and the { type, field } summary form; page metadata i18n resolves page:header title/subtitle; the filter logical combinators get one canonical conformance table; IHttpServer soft extensions and unmatched-request semantics are codified.
  • Liveness entries gain a verifiedAt re-verification clock, and a batch of ledger claims were re-verified against the real Console consumer — eight of the last ten preview-only live claims were wrong.
  • The ledger's security subset had its first full re-verification (#3896 follow-up): all 44 permission/position/object-sharing entries call-graph-closed by hand and dated. It found the unenforced RLS enabled (fixed the same day) and the void priority (removed), refuted two standing suspicions (allowExport is enforced server-side; the transfer/restore/purge gates are pre-mapped fail-closed), and bound two new runtime proofs (permission.tabPermissions, permission.objects.writeScope) so the claims re-prove on every CI run.
  • A new check:empty-state gate scans the authorable surface — spec schemas and platform-object field descriptions, where #3896's "leave empty to share every record" actually lived — for statements declaring a permissive empty state, and requires each to be classified (scope / closed / open / output) with a rationale. Omission is the commonest authoring error a model makes; it must not also be the widest grant.
  • File-backed SQLite runs in WAL mode (#3941). SQLite's built-in rollback journal makes a writer wait for every reader and leaves an idle connection invisible to SQL — both wrong for the platform's normal shape, several processes on one file (a dev server, os migrate, a test run). The driver now switches such a database to WAL on connect, which is also what lets the os migrate occupancy check see an idle server instead of inferring one from file descriptors. Two things to know: app.db-wal / app.db-shm appear beside the database while a connection is attached (so back up with sqlite3 … ".backup", never a bare file copy), and WAL cannot work on a network filesystem — set OS_DATABASE_SQLITE_JOURNAL_MODE=delete there, which converts an already-switched database back.
  • Metadata-plane FLS (per-caller masking) is proposed as ADR-0106.

New in Console (Studio) — bundled objectui 17.0

This section covers the window bundled at rc.0: cf2d56e32a11 → 4a4829d0ef39 (.objectui-sha) — 128 objectui commits on top of the pin 16.1.0 shipped. The pin has since advanced — the rest of the line is under New in Console in the Landed since 17.0.0-rc.0 section below.

Files, actions and forms

  • The file-as-reference value shape is adopted end-to-end (ADR-0104 D3 wave 2), including a localized FileField upload widget.
  • One precedence for action target/execute, and server-side body stops being mislabeled; a modal action's target resolves as a page, not an object; the spec's disabled predicate is honored on every action-rendering surface; inline lookup action params get a real record picker.
  • Forms consume spec-aligned FormView buttons/defaults, and an invalid submit scrolls to and focuses the first errored field; the flow-node repeater stops committing during render.
  • Image fields render consistently and support click-to-zoom.

Approvals

  • Typed output pickers, dynamic decision-output fields, expression approver editing, quick-path guard and expression completion — the Console half of #3447 P2.
  • Approval Center triage, density and drawer readability passes; pending-approver chips labelled with their group; the admin override for a stuck request surfaced in the inbox; the timeline attachment chip shows its name and opens; the detail band honors the node's lockRecord instead of assuming every approval locks, and distinguishes "in approval (editable)" from locked.
  • The inbox renders against one ticking clock.

Data, grids and charts

  • The grid computes all eleven spec column summary aggregations, gates row Edit/Delete and bulk delete on the effective operation set, and shows the real match total under server pagination.
  • <ObjectChart> honors the spec ChartConfig author shape, its aggregate result-column naming is a contract, and its axis bindings are validated — a fieldless count aggregate no longer keys its value column undefined. ChartAxis.stepSize, ChartConfig.description and .height are honored.
  • Dashboards send widget query options to the server and order funnel stages by the pipeline; Kanban surfaces off-column records in an Uncategorized lane.
  • A toast fires when a save silently dropped read-only fields, and write warnings stop being lost on the detail page.
  • Real per-caller FLS is wired into import targets and grid columns, and the Import Wizard gains an "Import as historical data" option; the import preview validates email format up front.
  • Detail and form edit/delete are gated on the server's effective operation set (#3546), the same adjudication the grid rows use — the Console stops offering a verb the server would refuse.
  • Dashboard and chart widget filters resolve {current_user_id} (#3574).
  • The five per-view-type configs and ListView read the spec-canonical vocabulary (filter, $notContains, type/label/maxLength keys), and secret stays out of inline edit.

Flows, Studio and Setup

  • A paused screen flow is completable: visibleWhen is honored in render and validation, flow actions dispatch from every surface, and the runner stops tearing down its host.
  • Studio gains a first-class notify flow node, a "Record created or updated" start trigger, an enable.searchable toggle in the object settings panel, and step warnings in the Flow Runs panel; the never-firing record-change option is removed from the trigger picker.
  • Setup's datasource list shows the real connect verdict with the operator-facing reason; the sharing-rule dialog becomes usable (i18n, a picker that lists people, permission-aware CTAs); delegated_admin is reachable and both of its pickers are narrowed; scoped-invitation placement invites straight into a unit and its positions; the flow designer reads approver value sources off the schema, and approver values become record lookups (#3508).
  • Group tenancy posture affordances (ADR-0105 Phase 1): the org switcher becomes the write context, and records carry org attribution.
  • The API console lists the whole AI family and the routes that exist, and the tool preview stops linking to a 404.

Internationalization & quality

  • The locale backfill completes: all ten packs reach full key parity. The four highest-traffic namespaces are translated into the eight trailing locales, hand-rolled zh/en branches and pick({en,zh}) clones are retired, and en becomes the complete source of truth for grid import and set-password. The system-settings hub is localized.
  • ESLint runs on PRs across every package, and the last five unchecked packages are type-checked — which surfaced two runtime bugs hiding there.
  • Console build dependencies take three major bumps — maplibre-gl 5→6 (the plugin-map default import is dropped to match), chalk 5→6, and jsdom 29→30 (dev) — relevant if you build the Console from source.

Landed since 17.0.0-rc.0

These changes are on main after the rc.0 cut and roll into 17.0.0-rc.1 — backend from the pending changesets (334 at the time of writing, 40 of them major-class), Console from the objectui pin advancing 4a4829d0ef39 → 7d9734d5e321 (79 commits, released as objectui 17.1.0). Sourced from each repo's own changesets.

One reading note: this page documents the whole 17.0.0 train and has been kept current as the train moved, so the largest post-rc.0 landings are already documented in place in the sections above rather than repeated here:

  • ADR-0110 action-declaration admission (#3935) — including its post-rc.0 revision: the OS_ALLOW_UNDECLARED_ACTIONS valve was removed before 17 ships; the refusal has no opt-out.
  • ADR-0104 D2 action params strict by default (#3438) — the OS_ACTION_PARAMS_STRICT_ENABLED opt-in era ends; OS_ALLOW_LAX_ACTION_PARAMS=1 is the escape hatch — plus the D1 evidence gates (os migrate value-shapes, fresh-datastore attestation, released-file collection behind the verified file migration).
  • ADR-0113required becomes the write contract; storage.notNull owns the column.
  • ADR-0114 — the closed field-error catalog and the fieldErrorsfields rename (#3977).
  • The #4001 unknown-key strictness campaign's later clicks — permission sets + flows, RLS / sharing rules / positions, approval configs, hooks + datasources, and the app shell & navigation tree, all .strict() with self-fixing errors.
  • The seven protocol-17 retirementsapi.requireAuth (#3963), the wait timeoutMs/onTimeout pair (#4158), query.joins/windowFunctions (#4286), the fields[] object form (#4196), query.cursor / distinct (#4286), BatchOptions.validateOnly (#4052) — and #4350's correction re-labelling them protocol 17 (their tombstones said "18", so os migrate meta was stepping over the two stack conversions and the generated upgrade guide omitted all seven).
  • The #4212 retirement family — the plugin lifecycle hooks, the typed-event cluster, ObjectQLEngine.use(), the Dev Mode Plugin Protocol (#4149).
  • The #3896 security sweep's enforcement half — criteria-less sharing rules share nothing, RLS enabled is enforced, RLS priority and the four inert tool keys are removed.
  • Flow errorHandling.maxRetries has one default (#4247), per-run flow summaries (#4354), and file-backed SQLite in WAL mode (#3941).

What follows is the rest of the window: first the metadata- and protocol-facing changes an upgrading application developer must read, then the platform capabilities an administrator gains, then the Console delta.

Breaking / behavior — protocol & wire

  • The actions route speaks HTTP (#3962, #3951, #3937, #3913). The accidental 200-with-inner-envelope double wrap is gone; the contract is now identical to /data: ran-and-returned = 200 {success: true, data: <handler return>}, single wrap; ran-and-rejected (business rule / validation) = 400 with error.details.code: 'VALIDATION_FAILED' | 'FLOW_FAILED' and per-field fields[]; not-dispatched = a real 404/403/503 (an unknown action used to come back {"success":true,"data":{"success": false}}); crashed (TypeError, driver class, sandbox timeout) = 500, visible to gateway error rates and APM instead of arriving as a green 200. Five defects traced to the extra layer, including the Console's green toast on failed actions and redirectUrl never firing. Object-less actions canonically key on 'global' and the POST /actions//:action shape is mounted. client.actions.* callers need no change — the SDK still never throws, and folds every failure into {success: false, error}.
  • POST /actions/:object/:action dispatches on the declared type (#3915). A flow action executes via the automation service as the calleruserId / positions / permissions / tenant forwarded, so a runAs: 'user' flow enforces RLS as the invoker instead of the user-less UNSCOPED path — and the params bag is seeded with recordId (+ the declared recordIdParam); url/modal/form/api actions answer 400 naming what to call instead of a registry miss. Standalone defineAction artifacts and Studio-authored action rows now resolve on REST with their requiredPermissions enforced (previously MCP-only). Script action bodies run with a bound, attributed ctx.api — owner-scoped writes stop dying FORBIDDEN, and body writes are stamped with the caller and tenant and join the open transaction.
  • A list query either applies or fails (#4121, #4134, #4164, #4181, #4226, #4254, #4256). Every input of the list/read surface now either takes effect or answers 400 — nothing is silently dropped: a ?filter= that does not parse (or parses to a non-filter) is 400 INVALID_FILTER — it used to return the unfiltered table as a clean 200; unknown fields in sort / select / expand / searchFields / groupBy / aggregations are 400 INVALID_SORT / INVALID_FIELD (a typo'd select returned every column against FLS and data minimisation; an unknown groupBy collapsed N groups into one; sum(<typo>) folded to 0 — indistinguishable from a real zero in a report); an unknown bare query parameter is 400 INVALID_FIELD with the canonical suggestion (?pageSize=5 used to become a zero-matching filter answering total: 0); a dotted-path sort (?sort=account.name) is rejected with the denormalisation prescription; explicit filter + bare field parameters compose with $and instead of dropping the implicit half; and two sort spellings that silently never applied — the SDK's declared orderBy: string[] and the {field: direction} map — now actually sort. Export honours the searched view (search= / searchFields=, #4181).
  • Unsorted paged reads are deterministic (#4363, #3821). A limit/offset read with no orderBy — the shape every list view without a configured sort sends — is now ordered by the unique key on SQL and MongoDB, and any non-empty orderBy gains a unique tie-breaker, so page 2 can no longer repeat a row while another row is never served. The background walks seek instead of counting for the same reason: seven scans paged with a growing offset while writing to the rows they were reading, so rows slid past the cursor unvisited — which meant rebuildApproverIndex deleted index rows for requests it skipped (an approver silently dropped from someone's queue), verifyFileReferences reported referenced files as unreferenced, the file and pinyin backfills left rows unconverted under a run that reported success, and scanValueShapes — which opens the value-shape migration gate on its evidence — could vouch for rows it never read.
  • SqlDriver.findOne(object, id) is removed. An undeclared bare-id branch that was on no contract, that nothing outside the driver's own tests used, and that the other two drivers answered differently. Pass a query. In the same pass bypassTenantAudit becomes a declared DriverOptions member (it was live but read through a cast, so no caller was type-checked), with its limit stated: it silences a diagnostic and must not change which rows a write touches.
  • Query options fold once, everywhere (#3795, #4346, #4371). The five RPC aliases (filterwhere, selectfields, sortorderBy, skipoffset, populateexpand) resolve through one spec-owned fold with one precedence — four of five pairs were inverted between readers, so ?select=a&fields=b answered [a] on one path and [b] on the other. The fold now covers every engine method: findOne / count / update / delete / aggregate with {filter} used to match every rowupdate(data, {filter, multi: true}) rewrote the whole table and delete({filter, multi: true}) emptied it. Conflicting spellings ({where: X, filter: Y}, {top: 1, limit: 3}) are refused naming both; direct engine callers passing wire-only spellings or unknown option keys now get an error naming the canonical key instead of silence (#4371) — which also fixed queue purge, which passed a key the engine never read and so deleted nothing.
  • query.having is enforced (#4286 follow-up). Declared since AST v2 and executed by nothing, having now filters aggregated rows on both the native-driver path and the in-memory fallback, with $and/$or/$not and loud rejection of unknown operators. A query that carried it was silently returning every group.
  • Request bodies validate at the entry (#3899, #3878). Catalog-declared requestSchemas are enforced with 400 VALIDATION_FAILED + fields[]: a garbage POST /data/:object/query body used to degrade into an unfiltered full read, POST /automation/:name/toggle {"enable": false} used to enable the flow and answer 200, and a misnamed POST /notifications/read key was a 200 no-op. /analytics/query and /analytics/sql validate the bare AnalyticsQuery at the entry — the {cube, query, format} envelope dialect of the retired shim is tombstoned, and the filter field is where, not filters — instead of dying downstream as a 500 SQL syntax error.
  • Bulk writes are bounded and bound to the path (#3939, #3933, #3897, #3946). The declared 200-row batch cap is now real on all five bulk routes (400 BATCH_TOO_LARGE, governed by batch.maxBatchSize, 1..1000); a body object can no longer move a bulk write or a /data/:object/query read to a different object than the gated URL object; deleteMany builds its predicate from the validated id list only — caller options.where used to widen a one-id request into deleting everything the caller could see — and per-id deletes now run deleteBehavior cascades and RLS/FLS under the caller (its response becomes the structured BatchUpdateResponse); and a caller-supplied context is dropped at ingress on these paths (#3960 — it could previously become the execution context, {isSystem: true} included, where no server context resolved).
  • Response envelopes converge (#3843, #3983, #4038, #4053, #4224). The settings, datasource-admin, external-datasource, package and share-link route modules emit the declared envelope — payload under data, errors as structured {code, message} on the ADR-0112 ledger — which un-broke two shipped SDK methods (client.shareLinks.create() / .list()); the dispatcher's duplicate top-level payload keys are gone; GET /ai/agents (the last unenveloped SDK route) conforms; /api/settings/* error extras move under error.details, with SETTINGS_VALIDATION.fields becoming ADR-0114 FieldError[]. Raw-fetch callers of these routes add one .data hop and read error.code; SDK callers are shielded — and the client now normalizes both server envelope families, so err.code is always the semantic string, never the HTTP status number (#3918).
  • Field validation speaks the caller's language (#3957). Built-in field-validation messages render in the request's locale (en, zh-CN, ja-JP, es-ES — Accept-Language / ?locale, falling back to the workspace locale), name the field by its translated label, and carry a structured constraint (min/max/actual/…). Match on code and constraint, not English message text; any built-in is overridable per deployment via validation.field.<messageKey> translation keys.
  • An absent capability answers honestly (#4093, #4113, #3891, #4087, ADR-0115). The last fabricating fallbacks are deleted. A mounted domain with no implementation answers 501 naming the package to install (/automation, /notifications, /ui/*, /ai/* — previously misleading 404/503s that sent operators chasing phantom routing bugs). /auth/* without an auth service answers 501 instead of a 200 carrying a fabricated session for any email and any password — the mock shipped in production @objectstack/runtime and told clients the one thing a server must never lie about. The degraded analytics shim — which dropped the caller's ExecutionContext, so authenticated callers got aggregates over rows RLS would hide, and read a non-contract filters key — is removed: without @objectstack/service-analytics the routes are not mounted (404), and discovery reports analytics: unavailable instead of hardcoding it always-on (#3989, #4010). The dispatcher's dead /storage bridge (which could never complete a request, and whose wildcard shadowed the real presigned service-storage routes) is removed. DevPlugin's stub table is retired — an empty slot in dev behaves exactly as in production, and the allow-all permissions / no-row-filter RLS / unmasked-field dev fakes now fail closed like production does. Discovery computes every slot's verdict from the registered service's own __serviceInfo (the _dev: true marker is retired), its remedy strings name real packages (ten of fifteen previously named packages that do not exist), and a slot self-declaring handlerReady: false is answered like an empty slot instead of having its fabricated 200 served (#4058, #4089, #4130).
  • HonoServerPlugin is a transport adapter (#4073). Its raw data-C+R surface, its third (pre-DiscoverySchema) discovery payload and the registerStandardEndpoints flag are deleted — data and discovery each have exactly one owner (@objectstack/rest; the runtime dispatcher). Every composed host (os serve, objectstack dev, cloud) already answered byte-identically. The three current-user endpoints (/auth/me/permissions, /auth/me/localization, /me/apps) register unconditionally — no longer hostage to the flag — yield correctly around the auth wildcard in either registration order (#4088, #4117), and resolve per request through the multi-tenant kernel-resolver seam, so authenticated tenant callers on a routing host stop being told {authenticated: false} (cloud#927).
  • The memory driver is pure in-memory again (#4065, #4083). A bare new InMemoryDriver() — and a declared driver: 'memory' datasource — silently wrote .objectstack/data/memory-driver.json into the working directory and reloaded it on the next boot; the persistence: 'auto' default drifted from the accepted opt-in design (#815) and is now false. Durability is explicit ({ persistence: 'file' }, per-datasource destinations), and the driver's "production-ready" claim is trimmed: it stores no constraints — use in-memory SQLite when constraints matter. os init scaffolds stop naming driver-memory as a dependency.
  • The artifact-api metadata source is removed (#4246). Zero consumers existed and half its URL contract had been dead since v5.0. { mode: 'local-file', path } is the single artifact source (it accepts the public/commit-pinned cloud URLs); a still-configured artifact-api throws loudly at start() instead of silently falling through to filesystem scanning.
  • Smaller wire corrections: the plural /meta/:type spellings no longer skip the book-audience / app-RBAC / dashboard gates the singular spellings enforced (#3984); book.audience: 'public' genuinely serves anonymously on secure deployments (#3963 step 2) — review books that declare it, they are now really public; three orphan operator vocabularies with zero importers leave the spec (objectui#2945 Track A); DriverPlugin's inert {datasourceName, registerAsDefault} options are retired (#4320); the legacy _dev: true service marker is retired in favour of __serviceInfo; and exported spec types stop resolving to any (NavigationItem, FormField, QueryAST['fields'], the z.input sides of the recursive schemas) — authoring code whose invalid shapes previously compiled clean now fails tsc with the real error, gated by the new check:exported-any (#4171, #4195, #4221).

Breaking / behavior — metadata authoring & runtime

  • Flow-node config is enforced end to end (#4277, #4045, #4027, #4347, #4389). The twelve contract-carrying builtins parse() their config before executing — a violation refuses the node as a guard naming every violated path; registerFlow rejects undeclared config keys (exact path, declared key set, did-you-mean, per-key tombstones like screen.visibleIfvisibleWhen) and warns on keys a configSchema does not declare; declared bare-CEL slots are validated (screen.fields[].visibleWhen authored as '{var} == true' shipped a forever-paused run); and ADR-0031 regions (loop.body, parallel.branches[], try_catch.try/catch) are walked by conversions, validators and lint alike, so nested nodes stop escaping every check. A legacy {var} condition carrying an unresolved dotted reference now refuses instead of comparing strings — 'oppRecord.amount > 500000' was constantly true: a gate that never gated.
  • Flow-node aliases graduate to conversions (ADR-0087 D2). subflow and map config.flowflowName (#4278, #4045), notify's nested source: {object, id}sourceObject/sourceId, and connector_action's mis-rooted config lifts onto connectorConfig — Studio-authored connector nodes never dispatched; stored flows are healed at load. All protocol-17 windows applied by os migrate meta and at every rehydration seam, alongside the seven #3796 aliases documented above.
  • A blank hook target is refused. object: '' / [] silently registered the hook on every object in the tenant; a wildcard must now be the visible '*', and bindHooksToEngine's strict option actually fails fast. defineHook() lands so convention-scanned hook files get the same parse-at-import treatment as the rest of the define family (#4269).
  • Stored metadata replays the conversion chain (#3903). Studio- and API-written rows at rest go through the same ADR-0087 machinery as authored source at every read seam (including registerFlow rehydration) — a stored action with the removed execute dispatches via target again; boot hydration validates each row post-conversion and diagnoses invalid ones with [metadata_spec_invalid] instead of shrugging. The rows themselves can now be brought forward too (#4327): os migrate meta --stored replays the chain over sys_metadata and rewrites what still carries a pre-protocol shape, through the normal write path. Optional — the read path is the guarantee either way — but it is what makes the conversion pass a no-op on your data instead of a permanent shim.
  • Studio's metadata forms tell the truth (#3786). Four of seventeen forms had drifted from their schemas, so controls saved nothing: the Object → Capabilities toggles bound a key the schema does not declare (all seven dead — Track history, Searchable, API enabled, Files, Feeds, Activities, Clone), and the Fields grid offered sixteen undeclared keys (PII, Encrypted, Indexed, …) while missing the canonical spellings for five renames. Forms are reconciled and gated registry-wide; re-author anything configured through the dead controls.
  • Authored view filters compile (#3948, #4029). Eight canonical VIEW_FILTER_OPERATORS members (equals, before, after, …) that validation accepted but the AST refused now lower to real queries on every driver, and an uncompilable filter element throws instead of matching every row. saveMeta persists canonical operator spellings, so the ~30-entry legacy alias bridge stops growing and can eventually retire (objectui#2945).
  • Analytics stops guessing (#4157, #4128, #4033). An undeclared measure or an out-of-vocabulary measure type errors instead of silently answering COUNT(*) (revenue charts that were quietly row counts now say so); the silently-dropped filter family — $between, $null, $startsWith / $endsWith, inverted $exists: false, $notContains, and the $or/$not combinators — now actually filters, so dashboards stop drawing every row; time-bucketed queries project their bucket column (a trend chart got N values and no x-axis). Time-typed dimensions gain chronological default order, and ReportSchema.order becomes a declarable ordering lowered into the dataset selection (#3916).
  • Temporal correctness lands as one campaign (ADR-0053). Field.datetime and Field.time get one canonical storage form per backend — mixed epoch/ISO storage made a two-sided date window return zero rows on SQLite while matches existed, and MySQL moves to DATETIME(3) with UTC-pinned connections (REST datetime writes on MySQL previously failed outright); legacy columns converge at schema sync and os migrate plan lists the in-place work with row counts (#3912, #3994, #4047, #3954). A bare YYYY-MM-DD upper bound covers its whole calendar day across SQL / memory / MongoDB / the RLS write-side evaluator — dashboards lost the final day's rows after midnight, and a { $lte: '{today}' } RLS check policy denied every write made after 00:00 (#3777, #4042). The type-blind evaluators compare a JS Date against wire text correctly — fail-closed RLS was denying legitimate SDK writes whose payload carried new Date() (#4191). date NOW() defaults resolve UTC on every dialect (#4022). A shared conformance matrix in @objectstack/spec/data is asserted by five backends so none of these classes can silently re-drift.
  • Sharing authority is real authority (ADR-0111, #3902). The share-management surface authenticated the caller and then ran under SYSTEM_CTXany signed-in user could revoke anyone's share, enumerate who-sees-what, write self-grants, and define org-wide sharing rules. Now: canManageShares (system, record owner, Modify All Data, or hierarchy write-scope depth) is enforced in the service on every /data/:object/:id/shares verb; listShares is management-gated and the open sys_record_share read surface is self-scoped for non-admins; the whole /sharing/rules surface requires the new manage_sharing capability (seeded into admin_full_access); an edit-level share no longer confers delete (canDelete = ownership, write depth, or modifyAllRecords; bulk deletes stop widening through shares); revoke validates the share belongs to the URL's record; grants on objects the sharing gates never consult fail SHARING_NOT_ENABLED instead of sitting inert; and a record's owner or a Modify-All admin — not just the minter — can revoke a share link.

Security corrections

Beyond the sharing-authority work and the #3896 enforcement half documented above, four fail-opens were found and closed in this window — all ship in rc.1, and administrators should know what changed:

  • The project-membership gate never ran (#4127 sweep). enforceProjectMembership probed the auth service through a shape it never had, so userId stayed unset and a signed-in non-member passed the gate on every deployment with project scoping on. Found by the new typed service-slot lint; the lint stays on to keep the class closed.
  • Analytics answered without a caller (#3891). The degraded shim served RLS-free, tenant-unscoped aggregates (removed — see above).
  • Dev stubs inverted their decisions (ADR-0115, #4093). With plugin-security absent, dev filled the slots with allow-all / no-RLS / unmasked fakes behind one warn line; the slots now stay empty and fail closed, and plugin-dev refuses NODE_ENV=production outright (OS_ALLOW_DEV_PLUGIN=1 overrides, and now brands the boot loudly instead of starting silent, #3900).
  • Imported users skipped field coercion (#4251 sweep). The /admin/import-users route probed a method its service never had, so rows reached sys_user uncoerced on every deployment. The same typed-slot campaign fixed handleAuth reaching the (now deleted) mock instead of the real auth service, and /analytics/sql crashing 500 on providers without generateSql.

New capabilities (backend)

  • Approvals grow up as a decision surface. A decisionOutputs entry can declare required: true — an approve carrying a blank routing value is refused before any write, instead of the next node faulting or the run stalling (objectui#2955); a reassign writes structured reassign_from / reassign_to parties that timelines render without free-text archaeology (#4365); and a runAs: 'system' flow's writes are audited as svc:flow:<flowName> instead of "Unknown user" (#4366).
  • Run summaries learn honesty about side effects (#4395, #4396). connector_action and mutating http nodes report unmeasuredEffect (the platform cannot know whether crm.push_opportunity writes), an unmeasured_count column joins sys_automation_run, and the broken-sweep detector becomes selected > 0 AND acted = 0 AND unmeasured = 0 — without the third clause it would fire on every healthy connector-driven flow.
  • Scheduled jobs actually fire. Cron jobs registered before kernel:ready landed on a placeholder adapter that silently ignores cron schedules — in the default configuration a business plugin's cron jobs never ran. Early registrations now migrate onto the DB-backed adapter, so operators will observe scheduled work starting to happen.
  • os migrate keeps its promises (#3917, #3924, #3954, #3955, #3978). Zero database writes before the confirmation prompt (boot-time DDL is deferred and rendered in the plan); a busy SQLite database is detected — file-descriptor probe naming the holding pid, which works on the default rollback-journal mode — and refused without --force; the plan lists in-place datetime/time normalisation with row counts so large rewrites can be scheduled into maintenance windows; zh-locale deployments stop being told to drop their live pinyin __search companion columns as "destructive orphans"; and virtual formula fields stop appearing as forever-pending work.
  • Boot tells operators the truth (#4012, #4085, #4095, #4110, #4096, #4167, #4002). Plugin boot-phase warnings survive the startup banner (previously discarded wholesale — including the ADR-0110 action-governance inventory); os serve <config> boots a fresh project with no compiled artifact instead of dying with a misleading error, and grafts the config module's onEnable/functions onto artifact boots so declared script actions stop 404ing; a named artifact path that does not exist fails the boot naming the path — it used to print "Server is ready" over an empty platform; OS_STORAGE_ROOT actually takes effect (uploads land under .objectstack/data/uploads from the first byte) and the spurious every-boot "storage adapter swapped" warning fires only on a real move; stack.storage — never a declared key, silently ignored — is linted with the two working channels named (OS_STORAGE_*, Setup → Settings); and authored api.* keys survive the serve-time config merge instead of being replaced wholesale.
  • Platform infrastructure is composable (#4243, #4270, ADR-0116). sys_migration and sys_secret move to PlatformObjectsPlugin, so a kernel without storage or settings keeps its migration ledger and its encrypted-secret store — Field.secret() writes previously threw on settings-less kernels. The kernel Plugin contract gains declared ordering (optionalDependencies, requiresServices, providesServices) with boot-time validation that names both plugins, both slots, and the fix; PLATFORM_ALWAYS_ON_CAPABILITIES is a published export, so hosts stop hand-mirroring the always-on slate (cloud's mirror had silently lost sms, messaging and analytics). Graceful shutdown disconnects datasource pools through the one datasource path, and adopted pools are never closed by a kernel that does not own them (#3993).
  • Seeds load what you wrote (#3911, #3932). Natural-key arrays resolve for multiple: true lookups (previously dropped with a warn), and dropped unusable references are counted and named in the boot banner (showcase 42 ok / 3 lost links) instead of the load reading clean.
  • Authoring lints gather the evidence (#3786, #4120, #4271, #4254, #3991). The advisory lintUnknownAuthoringKeys reports every silently-stripped object/field key across all sixteen metadata collections — with rename/retirement guidance and nested-metadata descent — through defineStack, os validate and os compile; error-severity reference-integrity rules gate flow update_record / create_record writes to undeclared fields and stale searchableFields declarations; hook and action bodies get the same write-set analysis as advisories, plus a runtime report for dead ctx.record writes (#4345); and a field carrying both per-tenant unique: true and a global unique index is flagged before the second tenant finds out. All three commands now answer identically by construction — os lint and os compile were letting through react pages and readonly flow writes that os validate rejected, including the field resolution that gates (an unresolvable <ListView filters> predicate yields a silently empty list, indistinguishable from no data). Expect CI running os lint to newly fail on metadata that was already broken.
  • Service slots are typed contracts (#4251, #4127). IObjectQLEngine lands (the seven consumer-local stand-in surfaces die), getService resolves through a slot→contract ledger, and a shrinking baseline bans any-typed lookups. This campaign is what surfaced the membership fail-open, the import-coercion miss and the cron-adapter migration above — the ratchet keeps the class closed.
  • Declared UI slots become authorable. FormSection.pane places sections in split forms explicitly (objectui#2153), and element:button's properties.action gains InlineActionSchema — previously authorable only via as any (objectui#2997).
  • Package hygiene reaches consumers (#4261, #4248, #4097). CHANGELOG.md ships in every tarball again — 68 of 69 packages had silently severed the delivery path for exactly the FROM→TO migration notes an upgrading agent greps after a tombstone error; twenty packages stop shipping raw sources and tests to npm; os init stamps engines: { protocol: '^17' } so new packages join the ADR-0087 load-time handshake; and spec-changes.json, the generated upgrade guide and the spec_changes MCP tool actually report the 16 → 17 chain (they still said 16.0.0).

New in Console — bundled objectui advanced 4a4829d0ef39 → 7d9734d5e321

143 objectui commits across five pin moves, released as objectui 17.1.0. (The pin changesets enumerate 79 of them; one range under-enumerated its own window and is recorded by console-bebaebd39ace-backfill — the fixes it covers are folded into the list below rather than left to the changelog.)

  • The lockstep half of ADR-0110 — release-critical. The rc.0 pin predates the client fix, so the Console it built still posted action.target to /api/v1/actions/:object/:action. Against a 17 server — which resolves the declaration by name and refuses an unresolvable one — every target-bound script action would 404 from the shipped Console. The ADR-0066 D4 capability gate is now applied on every action surface, and a modal action is client-side only (its server fall-through is dropped).
  • Action feedback stops lying. A failed server action no longer shows the green success toast, redirectUrl finally fires, one source now owns the /actions envelope rule, and the Console reads the single-wrapped responses the server started sending (#3962). A server rejection that names fields marks those fields in the form; error-code branches survive the ADR-0112 rename; a 400 from the server no longer reads to the user as "check your connection"; and settings validation errors render against the fields that caused them.
  • Filters, sorting and export agree with the server. Every spec view operator is bridged onto the filter AST (the client half of the operator parity above); a view's own filter no longer vanishes when the user adds one, nor arrives as a predicate on columns that do not exist, nor depends on whether the query expands a lookup; a column-header sort orders the whole list rather than the visible page, and a string $orderby reaches the server as a sort instead of a list of character indices; sorting a lookup column stops ordering by an invisible key; exporting a searched list downloads the searched rows, not the unsearched superset; and every column resolver reads one identity key, resolved at ingestion and reported out loud.
  • Forms stop losing what you typed. A tabbed or sectioned modal keeps every tab's values and a split form keeps both panels' — previously only the active pane survived the save; a defaultValues change no longer discards the field being filled (and new defaults apply in the commit that renders them); leaving a page with unsaved input in a modal or drawer form is guarded; swapping recordId no longer leaves the previous record on screen; and a wizard that ends on a field-less review step can finish.
  • Edits are safer. Form edit saves send If-Match and surface 409 conflicts instead of silently overwriting; a wizard with allowSkip no longer submits past skipped fields; a select no longer wipes itself when its value outruns its options; numeric and boolean option values survive selection typed; multi-value lookup is selectable in inline edit and hydrates through a batched $in (showing loading rather than the empty placeholder); tabbed and split forms honour the form view's own columns.
  • Spec values render instead of failing three different ways. Three tiers of spec-declared values were silently wrong, validated into nothing, or red-boxed the surface; they now render. The spec→FilterBuilder operator table covers the whole view vocabulary, a spec series[].type draws (and a spec-shape series plots at all), a chart says so when its rows carry no category key instead of drawing an empty axis, a missing analytics capability stops rendering as an empty KPI, a legacy string row action runs instead of green-toasting a no-op, and a flow or action that failed under HTTP 200 stops reporting success.
  • Bulk actions become per-record. An object-declared bulk action runs over the selection, visible is evaluated per selected record, params render through the shared form-field widgets (lookup errors get Retry, sys_user params get the PeoplePicker), and a bulk delete clears the row checkboxes. The bulkEnabled derivation is dropped — the spec key is a tombstone.
  • Approvals and notifications. Decision outputs reach both decision surfaces; a reassign hand-off reads legibly and svc:* audit actors render as "System"; the Console mounts the notification surfaces, each spec displayType gets its own presentation, and the spec icon, config, position and action variant are read instead of forked or ignored; Attachments become a peer tab with a live count badge, translated.
  • Studio and the designers. A page button created in Studio can be given an action (the InlineActionSchema above is its contract); the script node's form authors what the executor actually runs; a published configSchema can no longer delete a node's sibling-block editors; and the flow inspector preserves sibling blocks. SplitForm honours the new FormSection.pane.
  • Types stop forking the spec. Page/App/Dashboard validate the spec's own fields instead of passing them through; navigation metadata stops losing spec fields the renderer already honours; the *Validation five and five more spec-named symbols are derived rather than copied; and the form-field boundary between spec and runtime is declared, with a tripwire against re-importing the wrong one. The SDUI public contract is curated and guarded against silent drift, with declared inputs for the page:* / element:* / record:* block families.
  • Resilience and chrome. A transient /me/permissions or /me/localization failure retries instead of stranding the app on its loading state; a 403 is no longer blamed on the network; ⌘K search is no longer capped at 8 objects; the chart view gets a label and an icon in the view switcher; and the developer-voiced default form subtitle is dropped.

Upgrade checklist

17.0.0

  • Node: move to Node 22+ (and pin CI to 22).
  • Export: add allowExport: true to every environment-authored permission set whose holders must keep exporting — package-shipped sets are re-seeded for you, and member_default deliberately does not carry the grant.
  • apiMethods: run node scripts/codemod/apimethods-legacy-to-primitives.mjs, replace legacy values with their primitives, and delete the key entirely if all six remain. Watch for a legacy-only whitelist stripping to deny-all.
  • Metadata renames: run os migrate meta --from <your current major> — it applies executetarget, conditionalRequiredrequiredWhen, the retired-key deletions (os migrate meta handles all of them), sharing fulledit, and every earlier step you skipped, in one pass.
  • Flows: declare runAs: 'system' on any flow that reacts to system writes and must act beyond one user's grants; otherwise ensure the trigger supplies a user.
  • Agents: move anything declared in agent.tools[] onto skills; drop stack.agents (the runtime has never loaded them).
  • Auth: plan for the better-auth 1.7 account-identity backfill — check the boot log for federated accounts whose IdP is no longer registered, and stamp or remove those rows.
  • Actions: check any programmatic caller that posts params — a bag the server used to accept silently now 400s if it misses a required param, breaks options/multiple/reference, or carries an undeclared key. The error names the param and the declared list. OS_ALLOW_LAX_ACTION_PARAMS=1 buys time for an integration you cannot reach today.
  • Files: run os migrate files-to-references (dry run first, then --apply) and reconcile — passing its self-check is what turns on strict media value shapes and released-file collection for this deployment, so read the report before you --apply. Do not reach for OS_DATA_VALUE_SHAPE_STRICT_ENABLED to get there: it opts every value class in at once, regardless of which migrations this deployment has actually run.
  • Reference and structured-JSON values: run os migrate value-shapes and fix what it reports before --apply. It converts nothing — the values it names are application data — and a scan that was truncated or could not read an object fails the gate even at zero violations.
  • Stored metadata (optional): run os migrate meta --stored to see which sys_metadata rows still carry a pre-17 shape, and --apply to rewrite them. Unlike the two above this opens no gate and nothing depends on it — those rows already read canonically, forever. It stops them re-converting on every load, keeps diffs and exports clean going forward, and gives you an exit code to assert on: nothing left to do exits 0.
  • Datasources: verify every declared datasource connects in every environment — a bound datasource that cannot connect now fails the boot instead of failing every later query. Delete readReplicas (os migrate meta does it). Nothing ever opened those connections, so read throughput is unchanged by removing them; if you need replica reads, front them behind one endpoint (pgpool, ProxySQL, an RDS reader endpoint) and point config there. Also re-check what you wrote under config: it is parsed against the driver's contract now, so a key that used to be ignored — and left the datasource on driver defaults — is rejected by name.
  • Sharing rules: rewrite sharedWith.type: 'group''team'; drop guest and owner-type rules; expect accessLevel: 'full' to convert to 'edit'. A rule must state its criteria — authoring one without is rejected, and a stored criteria-less rule stops granting (its materialised grants are revoked on the next reconcile). State the predicate, or use the object's sharingModel if everyone should read it.
  • RLS policies: delete priority (os migrate meta does it; outcomes are unchanged). Re-check any policy you set enabled: false on — disabling now actually withdraws its grant, which until v17 it silently did not.
  • Tools: delete category / permissions / active / builtIn from tool metadata (os migrate meta does it; none ever had an effect). To gate a tool, gate the underlying action (action.requiredPermissions); to withdraw one, remove it from the skills/agents that reference it.
  • Close-out sweep: run os migrate meta once more — it also strips action.shortcut/bulkEnabled, flow.active/template/node outputSchema/errorHandling.fallbackNodeId, the inert view keys, dashboard/widget aria/performance, agent.knowledge and skill.triggerPhrases. If you set flow.active: false expecting a flow to stop, set status: 'obsolete' — the flag never worked.
  • Anonymous access: delete api.requireAuth (os migrate meta does it). If you relied on requireAuth: false to serve something publicly, re-declare it narrowly — a public form view, a share link, or book.audience: 'public' — and make sure the stack mounts auth at all, or it now fails at boot.
  • Wait nodes: os migrate meta moves waitEventConfig.timeoutMs onto timerDuration and drops onTimeout. Anything that expected a wait to end on a deadline never did — build the deadline explicitly if you need one.
  • Query callers: these are request shapes, so the chain cannot fix them for you. Drop joins, windowFunctions, cursor and distinct from queries and the { field, fields, alias } object form from fields[]; replace QueryBuilder.cursor()/.distinct() call sites. Stop sending options.validateOnly to /batch, /updateMany and /deleteMany — it never previewed anything, it persisted.
  • Required fields: run os migrate meta — it stamps storage: { notNull: true } onto every pre-17 required: true field so your columns keep their exact constraints. New fields: required: true alone write-gates with a nullable column; add storage.notNull when you want the DDL. Audit any client that PATCHes null into required fields — that write is now rejected.
  • Membership: move business capability off sys_member.role and onto positions (sys_user_position).
  • SDK callers: remove calls to client.permissions.*, client.realtime.*, client.workflow.*, client.views.* CRUD, the notifications device/preference helpers, client.ai.{nlq,suggest,insights} and projects.listTemplates(); repoint marketplace publish to POST /api/v1/packages/publish; drop os environments create --template. Imports of IWorkflowService, WorkflowProtocol or the Get/WorkflowState/Config/Transition types no longer resolve — the workflow slot retired with them (#4451); use state_machine validation rules, approval flow nodes and record_change flows instead. Discovery responses no longer carry services.workflow / routes.workflow / features.workflow — a reader keying on them saw only unavailable/false before, so delete the read.
  • Console hosts: the same removals land in objectui — drop the useClientNotifications device/preference delegates (@object-ui/react) and replace the retired @object-ui/types Capabilities re-exports with imports from @objectstack/spec. If you build the Console from source, note the maplibre-gl 5→6 / chalk 5→6 major bumps.
  • Type importers: replace ObjectStackProtocol / ObjectStackProtocolSchema with the narrowest per-domain slices; drop GraphQL types and any of the removed dead spec clusters. If you imported MetadataFormat, MetadataStats, MetadataLoadOptions, MetadataSaveOptions, MetadataLoadResult, MetadataSaveResult, MetadataWatchEvent, MetadataCollectionInfo or MetadataLoaderContract from @objectstack/spec/kernel, change the path to @objectstack/spec/system — same names, and that copy is the one the runtime has always emitted. (MetadataExportOptions / MetadataImportOptions moved again in #4538: the system-side option bags turned out to have zero consumers and were deleted, so those two names now come from @objectstack/spec/contracts — the shape MetadataManager actually implements.) It is the looser of the two, so a reader may need new narrowing: notably metadataType/name/timestamp are optional there. (MetadataWatchEvent.type briefly also declared the raw watcher values add/change/unlink; they had zero producers — both emit sites normalize — and were removed in #4536, so the enum now carries exactly added/changed/deleted.) Nothing to migrate at runtime — the values the runtime emits were always these.
  • Multi-org: the group posture requires the enterprise runtime — deployments relying on it self-activating must install @objectstack/organizations or move to isolated.
  • Approvals readers: anything that listed requests tenant-wide must now be a participant or hold the admin override.
  • Raw-HTTP action callers: branch on the HTTP status — non-2xx is the failure, and a 200's data is the handler's return directly, one wrap less (#3962). Crashes are 500s now; treat them as server faults. SDK callers need no change.
  • List-query callers: malformed filters and unknown sort/select/expand/groupBy/aggregations/searchFields names now answer 400 instead of silently over- or under-returning — fix the request the error names, and note that previously-ignored orderBy: string[] and {field: direction} sorts now actually apply.
  • Raw-fetch readers of the settings / datasource-admin / external-datasource / package / share-link routes: read the payload under data and errors as error.code / error.message (#3843, #3983).
  • Bulk callers: stay under batch.maxBatchSize (default 200, raisable to 1000) or chunk; stop sending object / context in bulk bodies (ignored); read deleteMany's new structured BatchUpdateResponse (#3939, #3897).
  • Analytics clients: send the bare AnalyticsQuery shape (where, not filters; no {cube, query, format} envelope) (#3878). Embedded / programmatic hosts must install @objectstack/service-analytics — the context-less fallback is gone (#3891).
  • Bare HonoServerPlugin hosts: mount @objectstack/rest or the runtime dispatcher for data/discovery — the convenience surface and its flag are deleted (#4073).
  • Direct new InMemoryDriver() / driver: 'memory' users: pass { persistence: 'file' } if you relied on the accidental durability, and delete stale .objectstack/data/memory-driver.json files (#4065, #4083).
  • artifact-api sources: switch to the local-file URL form or os package install — a configured artifact-api now fails the boot (#4246).
  • Hand-built kernels: compose PlatformObjectsPlugin if you relied on storage/settings registering sys_migration / sys_secret (#4243, #4270); declare the ADR-0116 ordering fields on plugins that resolve services in init().
  • SQLite operators: back up with sqlite3 app.db ".backup …" — a bare file copy can miss committed transactions in the WAL sidecars — and set OS_DATABASE_SQLITE_JOURNAL_MODE=delete on NFS/SMB (#3941).
  • Dev setups: the fabricating stubs are gone — install the real plugin for any slot a dev call now finds absent, and note plugin-dev refuses NODE_ENV=production without OS_ALLOW_DEV_PLUGIN=1 (ADR-0115).
  • Sharing admins: add the new manage_sharing capability to the permission sets held by whoever administers sharing rules (admin_full_access carries it already), and expect edit-level shares to stop conferring delete (ADR-0111).
  • Flow authors: rename aliased node-config keys (subflow/map flowflowName, notify to/subject/body/urlrecipients/title/message/actionUrl, script functionName/inputfunction/inputs) or run os migrate meta — the conversion windows close at protocol 18 (#3796, #4278, #4045).

References

ADR-0104 (field runtime value-shape contract / file-as-reference) · ADR-0105 (group tenancy posture) · ADR-0106 (metadata-plane FLS, proposed) · ADR-0108 (membership grade is not capability) · ADR-0109 (agent tools from skills) · ADR-0076 D9/D11 (protocol alias dissolution, dispatcher decomposition) · ADR-0087 D4 (change manifest / migrate meta) · ADR-0049 (enforce-or-remove) · ADR-0078 (loud at the producer) · ADR-0090 D3 (team recipient) · #3825 (Node 22) · #3544/#3710 (export axis) · #3543/#3391 (ApiMethod derivation) · #3760 (user-less runs) · #3855 (alias retirement) · #3820 (agent authoring) · #3590 (approval visibility) · #3865 (sharing full) · #3617 (files-to-references migration) · #3447 (dynamic approver routing) · #3563/#3587/#3612/#3718 (route ledger + SDK surface) · #2462 (GraphQL removal) · #3741/#3758/#3826 (datasource fail-fast) · #3696 (per-tenant unique) · #3676/#3778/#3847 (i18n contract conformance).

Landed since rc.0: ADR-0110 (action declaration admission) · ADR-0111 (sharing authority) · ADR-0112 (error-code vocabulary) · ADR-0113 (required split) · ADR-0114 (field-error catalog) · ADR-0115 (no fabricating fallbacks) · ADR-0116 (declared plugin ordering) · ADR-0053 (temporal semantics) · #3962/#3951 (actions speak HTTP) · #3915 (action type dispatch) · #4121/#4134/#4164/#4181/#4226/#4254/#4256/#4363 (list queries apply or fail) · #3795/#4346/#4371 (one alias fold) · #3899/#3878 (request-body validation) · #3939/#3897/#3933/#3946/#3960 (bulk binding + caps) · #3843/#3983/#4038/#4053 (envelope convergence) · #3957 (localized validation) · #4093/#4113/#3891/#4087 (honest absence) · #4073 (Hono transport adapter) · #4065/#4083 (memory-driver persistence) · #4246 (artifact-api removal) · #3903 (stored-metadata conversion replay) · #4277/#4045/#4027/#4347 (flow config enforcement) · #3948/#4029 (view-filter operator parity) · #4157/#4128 (analytics stops guessing) · #3916 (report ordering) · #4350 (protocol-17 relabel) · #4127/#4251 (typed service slots + fail-open fixes) · #3917/#3924 (os migrate occupancy + deferred DDL) · #4243/#4270 (platform-objects infrastructure) · #4395/#4396 (unmeasured effects) · #4365/#4366 (approval reassign + audit attribution) · #4261/#4248 (published-files hygiene).

On this page

Highlights — 17.0.0Breaking changes & migrationNode.js 22 is the supported floor (#3825)allowExport is opt-in — export no longer inherits read (#3544, #3710)enable.apiMethods shrinks to the six primitives (#3543)An action must be declared to be invocable, and is identified by name (ADR-0110, #3935)An action's declared params are enforced at dispatch (ADR-0104 D2, #3438)A flow run with no trigger user may not touch data (#3760)The last three deprecated authorable aliases are removed (#3855)Seven flow-node config key aliases graduate into the conversion layer (#3796)Authorable schemas reject unknown keys (#4001)agent.tools[] is removed — capability comes from skills (ADR-0109, #3820)Error codes are one SCREAMING_SNAKE vocabulary, and error.code is a closed set (ADR-0112, #3841)Field-level error codes are their own closed catalog, and fieldErrors becomes fields (ADR-0114, #3977)Approval requests are visible to participants, not the whole tenant (#3590)Sharing: the full access level is gone, and the recipient enum matches the runtime (#3865, #1878)Sharing rules: an empty criteria shares nothing (#3896)RLS: enabled is enforced, priority is removed (#3980, #3990)required is a write contract; the column constraint is storage.notNull (ADR-0113)Membership grade is not a capability channel (ADR-0108, #3723)better-auth 1.7.0-rc.2 — account identity restructuringDead SDK surface deleted (#3612, #3702, #3718, #3731)The ai namespace now expresses the AI surface that exists (#3718)The GraphQL surface is removed (#2462)The ObjectStackProtocol composition alias is dissolved (ADR-0076 D9, #3606)A datasource that cannot connect fails the boot (#3741, #3758, #3826)unique materializes per tenant (#3696)Aggregation result shapes (#3839, #3849)i18n routes answer in the shapes they declare (#3676, #3778, #3847)The dispatcher's error.code is the semantic code, not the HTTP status (#3842)Anonymous access is denied unconditionally — api.requireAuth is gone (#3963)wait never had a timeout — timeoutMs / onTimeout are retired (#4158)The query request surface sheds five inert keys (#4196, #4286)findOne must say which record it wants (#4419)Dead spec clusters removedSmaller breaking changesNew capabilities in 17.0.0Files become platform records (ADR-0104)Approvals: dynamic approver routing (#3447)The SDK reaches the whole REST surface (#3563, #3587)Write observability — a silent strip stops reading as a clean saveAnalytics correctnessAutomation & flowsHistorical data importLint & CLISpec, kernel & platformNew in Console (Studio) — bundled objectui 17.0Files, actions and formsApprovalsData, grids and chartsFlows, Studio and SetupInternationalization & qualityLanded since 17.0.0-rc.0Breaking / behavior — protocol & wireBreaking / behavior — metadata authoring & runtimeSecurity correctionsNew capabilities (backend)New in Console — bundled objectui advanced 4a4829d0ef39 → 7d9734d5e321Upgrade checklist17.0.0References