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 thelatesttag untilchangeset pre exit. Section headings say 17.0.0 for brevity. Caret ranges on^16.xhold at 16.x until you opt in, which is the reason this train is a major at all: its breaking density (theApiMethodshrink, the GraphQL removal, the ADR-0104 write cutover, the dead-cluster retirements) is too high to auto-upgrade^16.xconsumers 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 opaquesys_fileid; the{url, name, size, …}object becomes the read (expanded) form. The platform owns the bytes, soaccept/maxSizeare 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-referencesperforms the conversion with a self-check and records a per-deployment flag. - Bulk export is its own privilege.
allowExportunset 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 — andviewAllRecords/modifyAllRecordsno longer confer export either. - The SDK reaches the surface that exists — and only that surface.
21 dead methods (plus the entire phantom
ainamespace) 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
expressionapprovers (CEL overcurrent.*/trigger.*/vars.*), a node-levelonEmptyApproverspolicy (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;/readyanswers 503 when one stops answering; and thedefaultdatasource 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/countRequestsapplied 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: falsekept 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 RLSpriorityknob 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.nodesaid>=18across 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, reportaria/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.activeandagent.knowledgeamong 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 22On 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)
| before | after | |
|---|---|---|
allowExport unset | export allowed (inherited read) | export denied |
allowExport: false | denied | denied (unchanged) |
allowExport: true | allowed | allowed (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_accessandorganization_admincarryallowExport: truefor you. Environment-authored sets are not — edit any custom set whose users export.member_defaultdeliberately 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
truegrants export;falseand unset are the same outcome. viewAllRecords/modifyAllRecordsno 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
allowExportis now high-privilege, so it cannot be bound to theeveryone/guestaudience 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 |
|---|---|---|
upsert | create, update | upsert ⊆ create ∧ update |
import | create, update | import ⊆ create ∨ update |
export | list | export ⊆ list |
aggregate | list | aggregate ⊆ list |
search | list | search ⊆ list ∧ searchable |
history | get | history ⊆ 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.mjsThe 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, MCPrun_action, and every future surface identify an action by its declarativename.targetis 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 postsname(objectui ships in lockstep). - An undeclared handler is refused.
engine.registerActionwith no matching declaration has norequiredPermissionsto enforce and no param contract to check, yet executes with system privileges. It now returns 404 naming thedefineActionto 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).
| Violation | Example |
|---|---|
missing required param | declared p_text absent from the bag |
value outside options | p_priority: 'NOT_AN_OPTION' |
multiple shape | a bare string where an array is declared |
reference shape | a non-id where reference: 'sys_user' is declared |
| undeclared key | bogus: 123 |
- OS_ACTION_PARAMS_STRICT_ENABLED=1 # removed — enforcement is the default
+ OS_ALLOW_LAX_ACTION_PARAMS=1 # escape hatch: warn and pass, as beforeOnly 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)
| Removed | Use instead | Value shape |
|---|---|---|
action.execute | action.target | unchanged |
field.conditionalRequired | field.requiredWhen | unchanged |
agent.knowledge.topics | agent.knowledge.sourcesknowledge 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) | Deprecated | Use instead |
|---|---|---|
get_record / create_record / update_record / delete_record | config.object | config.objectName |
notify | config.to / config.subject / config.body / config.url | config.recipients / config.title / config.message / config.actionUrl |
script | config.functionName / config.input | config.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 object → objectName 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 sets —
PermissionSetSchema,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-sideEffectiveObjectPermissionSchemastays wire-tolerant. - Flows —
FlowSchema,FlowNodeSchema,FlowEdgeSchema,FlowVariableSchema. A node'sconfigstays an open record: it is per-node-type, owned by the executor'sconfigSchemaand the conversion layer (see the alias table above). - RLS policies —
RowLevelSecurityPolicySchema. 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,withCheck→check,condition/filter/where→using). The runtime evaluation shapes stay tolerant. - Sharing rules — the rule, its criteria extension, and the
sharedWithrecipient (criteria→condition,access→accessLevel,recipient→sharedWith;ownedBycarries the removed owner-type-rule prescription). - Positions —
PositionSchema, including the guidance thatpermissionSets/usersare runtime bindings andparenthas no meaning on a deliberately flat position (ADR-0090 D3). Position also gains theprotectionblock and ADR-0010 runtime envelope every sibling registered type already declared. - The app shell —
AppSchema, its branding / area / context-selector / contribution blocks, and the whole navigation tree. The nav-item union is now DISCRIMINATED ontype, so one unknown key yields one precise issue against the branch you wrote (at an exact path through nestedchildren) rather than a nine-branchinvalid_unionwall. Per-target payloads (paramson page / component / action items) stay open. The gate's first catch here was in the platform's own Account app: three navigation groups declareddefaultOpen— never a schema key — and so shipped collapsed while their author believed they opened by default (expandedis 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→ theapprove/rejectout-edges,rejectionBehavior→ a declared back-edge). The published JSON schema carriesadditionalProperties: falseinto the Studio form andregisterFlow()config validation, so a mis-keyed approvalconfigis rejected at registration too. - Hooks —
HookSchema, itsretryPolicy, and both hook-body branches (expressionandjs). A body was the worst place to lose a key quietly: a misspeltcapabilitiesstripped to the empty default and the sandbox then threw at invocation time, on whichever code path first touchedctx.api— far from the typo. A misspelttimeoutMs/memoryMbsilently 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-leveltimeoutvs body-leveltimeoutMs, and hookretryPolicy.backoffMsvs datasourceretryPolicy.baseDelayMs.HookContextSchema— the runtime shape the engine hands your handler — stays tolerant and always will. - Datasources —
DatasourceSchemawith itspool/healthCheck/ssl/retryPolicyblocks, the ADR-0015externalfederation settings and theirvalidationpolicy,DatasourceCapabilities, andDriverDefinitionSchema.configstays an open record: its shape is per-driver. What it no longer is, is unvalidated — #4410 wired the per-driver schemas (PostgresConfigSchemaand siblings) intoDatasourceSchema'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 ownconfigSchemadid 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 (hostnext todriverinstead of insideconfig) was stripped, and the datasource then connected on driver defaults rather than failing. Those keys now prescribe the move intoconfig; a top-levelpasswordis instead pointed atexternal.credentialsRef, because relocating an inlined secret is not the fix. A dropped key incapabilitieswas quieter still — though not for the reason this note used to give. It claimed an unregistered capability "reads asfalse, 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 wholecapabilitiesblock has no reader at all. The engine gates pushdown on the runtime driver's ownsupports.*object, a different mechanism with a non-overlapping vocabulary. Every key in the block isdeadinliveness/datasource.json, andcapabilities.readOnlyis the one to know about: it reads as a safety switch and gates nothing —external.allowWrites: falseis 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 (steps → nodes, edge
from/to → source/target, read → allowRead, tabs →
tabPermissions), 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; isProfile → isDefault, 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/unauthenticated → UNAUTHENTICATED, forbidden →
PERMISSION_DENIED, not_found → RESOURCE_NOT_FOUND, internal →
INTERNAL_ERROR, unavailable → SERVICE_UNAVAILABLE, not_supported →
NOT_IMPLEMENTED, bad_request → INVALID_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 plainedit. 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 viasys_team/sys_team_member. The old spelling was silently skipped at seed time.business_unitis added to the authoring enum — exactly one business unit's members, no subtree (useunit_and_subordinatesfor the subtree). The runtime already enforced it; only the enum omitted it.guestand 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:
defineRulerejects a match-all criteria withVALIDATION_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_ruleinsert 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: falsenow 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;enabledabsent stays active, so no stored policy changes behaviour. Access-narrowing only — but re-check any policy you setenabled: falseon expecting no effect, because until now it had none.priorityis 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 metadeletes 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: trueis 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 theNOT NULLDDL and the destructive-migration ceremony (backfill first). Declaring it at field creation is free; declaring it over existing nulls is loud.requiredWheninherits 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×requiredWhenis rejected at parse — a conditional contract cannot be an unconditional constraint.- Pre-17 sources keep their exact meaning:
os migrate metastampsstorage: { notNull: true }onto every previously-required field (field-required-notnull-explicit) — under the old semantics that column was createdNOT 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 isneeds_confirm(ratify or relax — dev auto-reconcile no longer silently strips a strayNOT NULL), and silent when the field isrequired(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' }→ TOfields: { issuer: 'issuer', providerAccountId: 'account_id' }. The provider account id keeps itsaccount_idcolumn;sys_accountgains anissuercolumn. - FROM
internalAdapter.createAccount({ providerId, accountId, … })→ TOcreateAccount({ providerId, issuer, providerAccountId, … }). A local password account carries better-auth's ownlocal:credentialissuer. - FROM
client.auth.accounts.unlink({ providerId, accountId })→ TOunlink({ accountId }), whereaccountIdis the account row id fromaccounts.list(). That listing now returnsissuer+providerAccountIdin place ofaccountId.
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-realtimeregisters zero HTTP routesclient.workflow.*(getConfig, getState, transition)client.views.*CRUD — there is no/ui/viewsroute anywhereclient.notificationsdevice/preference helpers — the ADR-0012 server side was never builtclient.ai.{nlq,suggest,insights}— the whole namespace, rebuilt belowclient.projects.listTemplates()— targetedGET /api/v1/cloud/templates, mounted by nothingos environments create --templateand itstemplate_idbody 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)
| SDK | Route |
|---|---|
ai.chat(request) | POST /api/v1/ai/chat — forces stream: false |
ai.chatStream(request) | POST /api/v1/ai/chat — AsyncIterable 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
externaldatasource withvalidation.onMismatch: 'fail'fail-fasted; everything else degraded to onewarnline. An app declaringdatasource: '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 withDatasource 'x' is not registered. objectql.init()refuses to boot when a data driver fails to connect.- The standalone
defaultdatasource is now a declaration, connected through the oneDatasourceConnectionServicepath instead of being pre-built and smuggled in as adriver.*kernel service with its own copy of the failure policy. /readyreports 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
nullfor 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 SQLiteField.datetime, which used to collapse every row into one(null)bucket, and raw epoch storage thataggregate()/distinct()leaked.
i18n routes answer in the shapes they declare (#3676, #3778, #3847)
/i18n/labels/:object/:localeemits the declared entry object ({ label, help?, options? }) instead of a bareRecord<string, string>. A client typed againstGetFieldLabelsResponsereadlabels[field].labeland gotundefined; the SDK's type was right and the servers were wrong.helpandoptionsstop being discarded./i18n/localesanswers in one shape, found by a new success-envelope conformance suite that parses every/i18nsuccess body against the schema the route declares — rather than against a hand-written literal, which is what let three of these ship green.- The
translationmetadata type speaksobjects.<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-firsto.<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.tsfiles in the tree were alreadyobjects.-shaped — so this is a registration fix, not a migration. GetTranslationsRequestis locale-only. Thenamespace/keysfilters were declared, put on the query string by the SDK, and read by neither serving surface; passingkeysshrank 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.codeis the semantic string.error.httpStatusis the number.error.detailsis context only. Replace abody.error.details?.code ?? body.error.typeread withbody.error.code, and abody.error.coderead (for the status) withbody.error.httpStatus.- SDK callers need no change.
ObjectStackClientalready normalised this —err.codesemantic,err.httpStatusnumeric. 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_FAILEDandunauthenticatedall 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 aStandardErrorCodefrom the status (403→PERMISSION_DENIED, post-rename spelling), spelled in one map in the spec. - Spec:
ApiErrorSchemagains optionalhttpStatus;StandardErrorCodegainsmethod_not_allowedandprecondition_required(both additive).DispatcherErrorCodechanges members from'404' | '405' | '501' | '503'to the four semantic spellings the removederror.typedeclared, andDispatcherErrorResponseSchema.error.codebecomes a string — it had declared the opposite ofApiErrorSchemafor 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 timeoutMs → timerDuration (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.
| Removed | Why | Use 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 ingress | expand, or a dotted path for a single related column (fields: ['owner.name']) |
query.joins | no 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.windowFunctions | find() never applied one, so every OVER clause was silently dropped; WindowFunctionNode declared field/over/frame members the live door never read | aggregations + groupBy; embedders on a SQL datasource call SqlDriver.findWithWindowFunctions() |
query.cursor | no 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 terminates | a where predicate on your sort key ({ created_at: { $gt: last.created_at } }) with the matching orderBy |
query.distinct | mis-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/hasMore | groupBy, 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 of | Write | Meaning |
|---|---|---|
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-0061search→ cross-field$containsexpansion ran infindonly, while both methods are checked against the same legal-key set — sosearchpassed 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 optionfindOnedeclares to have an observable effect.MongoDBDriver.findOnenow appliesorderBy,fieldsandoffset. It translatedwhereand 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 filter → where 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.
| Removed | Note |
|---|---|
PortalSchema | portal 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.permissions | never gated anything (#3686) |
tool.requiresConfirmation | a safety flag nothing enforced (#3715) |
object.enable.trash / enable.mru | ADR-0049 enforce-or-remove close-out (#2377) |
ReportColumnSchema / ReportGroupingSchema + report chart groupBy | unread (#3463) |
Report aria / performance props | report-liveness close-out |
DataQualityRulesSchema / ComputedFieldCacheSchema | orphaned value schemas (#3726, #3733) |
DynamicLoadingConfig / PluginDiscoveryConfig / PluginDiscoverySource | promised plugin sandboxing / integrity verification / source allow-listing / load approval; none of it was ever wired (#3950) |
RowLevelSecurityPolicy.priority | conflict-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 / .bulkEnabled | no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions (#3896 close-out) |
flow.active / .template / node outputSchema / errorHandling.fallbackNodeId | active: 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/aria | no 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.triggerPhrases | phrases were never matched; activation is triggerConditions + the agent's skills[] allowlist (#3896 close-out) |
DEFAULT_DISPATCHER_ROUTES | dead route table |
| Aspirational config on Theme / Translation / Webhook | still-dead after #3494 |
ChartInteraction.zoom / .clickAction | never 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 entry | declared 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.readReplicas | replica 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/integration — DatabaseConnectorSchema, 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/automation — ConnectorSchema, 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
groupposture no longer self-activates — it requires the enterprise runtime (@objectstack/organizations). The first ADR-0105 wave made it self-activating, which turnedgroupinto a free multi-org path around theisolatedgate and made the weaker isolation the free one. bootStack({ multiTenant: true })now requests theisolatedposture explicitly.- Kernel-built assignment notifications are dropped from
plugin-audit; the policy moves to user-space automation (#3403). sys_view_definition's all-sixapiMethodswhitelist is dropped (#3026).os migrate planshows index drift — index DDL is no longer applied silently at boot (#3728).- A flow's
errorHandling.strategy: 'retry'must statemaxRetries(>= 1) (#4247).maxRetrieshad two defaults —.default(0)inFlowSchemaand?? 3in the engine'sretryExecution— 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 isstrategy: '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 }, orstrategy: 'fail'if no retry was intended.maxRetries: 0stays legal under'fail'/'continue', which never read it. ObjectQLEngine.use()andObjectQLHostContextare removed (#4212 follow-up, #4242). The engine's own plugin loader — register a manifest part, then dispatch the runtime part'sonEnableover anObjectQLHostContext— had zero callers repo-wide, and itsonEnablewas 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 viactx.getService('objectql'), drivers viaengine.registerDriver()).new ObjectQL({ logger })andObjectQLPlugin'shostContextoption keep working, and the app-bundleonEnablemodule 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_fileid. 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. acceptandmaxSizeare declarable onFieldSchemaand 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_filecarries 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 raiseFileConstraintError. 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
urlfield, 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 flagThe 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 cleanA 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)
expressionapprovers. A CEL expression resolves who approves at node entry, over exactly three roots:current.*(the record's live state),trigger.*(the submit-time snapshot) andvars.*(flow variables, including upstream node outputs). Barerecordand bare field names are rejected before evaluation — on this platformrecordalways means "the record at event time", which is ambiguous at an approval node — with error messages that prescribe the correct spelling. OptionalresolveAs: 'user' | 'department' | 'position' | 'team're-expands each resolved id through the same graph lookups the static types use; withbehavior: 'per_group'each intermediate value forms its own sign-off group.onEmptyApproverspolicy, node-level, for every approver type:admin_rescue(default — the request opens for privileged takeover),fail, orauto_approve(skip the request and continue down theapproveedge withoutput.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 readvars.<nodeId>.picked_departments. Undeclared keys reject the decision;decisionandrequestIdare reserved. AdecisionOutputsentry 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/managerapprovers 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_BINDINGSis the single declaration of how a designer sources each approver row'svalue.queueis deprecated for authoring — it still parses so stored flows keep loading, but it is published inxEnumDeprecatedbecause the runtime has no queue resolution and the slot routed to nobody. - Also: cross-organization approver targeting,
departmentapprovers resolving against env-wide business units, per-group membership of pending approvers, inbox rows enriched with snapshot field labels (payload_labels), the pending node'slockRecordpolicy 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
ObjectQLStrategyenforces the read scope (RLS + tenant), and the read-scope auto-bridge no longer depends on plugin order.timeDimensions[].dateRangeis applied — the predicate every date-bucketed chart was missing.- The effective date granularity drives bucket labels and drill ranges, and
widget
dateGranularity/sortBy/sortOrder/limitare 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/skippedtotals, a per-node breakdown, and which gate closed — on the run result, inlistRuns/getRun, as one greppable log line (selected=30 acted=0 skipped=30 gate=check_stalled->send_nudge:30), and as queryableselected_count/acted_count/skipped_countcolumns onsys_automation_run.success: truenever 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 == 0over consecutive runs is now the detector. See Run summaries. record-after-writefires one flow on create or update (#3427), withpreviousbound asnullon 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
faultedge 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-formtriggerTypefails loudly instead of silently never firing; string templates serialize object tokens readably instead of[object Object]. retryPolicy/timeoutauthored 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
ISecurityServiceis published — thesecurityservice surface becomes an enforced contract, withsecurity.getReadableFieldsfor 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-resolverseam. - An action rejects a
bodyon a non-script action and rejects unknown keys on an action param instead of stripping them; an inlinelookupparam can declare its reference target. ListColumngainsprefixand the{ type, field }summaryform; page metadata i18n resolvespage:headertitle/subtitle; the filter logical combinators get one canonical conformance table;IHttpServersoft extensions and unmatched-request semantics are codified.- Liveness entries gain a
verifiedAtre-verification clock, and a batch of ledger claims were re-verified against the real Console consumer — eight of the last ten preview-onlyliveclaims 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 voidpriority(removed), refuted two standing suspicions (allowExportis 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-stategate 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 theos migrateoccupancy check see an idle server instead of inferring one from file descriptors. Two things to know:app.db-wal/app.db-shmappear beside the database while a connection is attached (so back up withsqlite3 … ".backup", never a bare file copy), and WAL cannot work on a network filesystem — setOS_DATABASE_SQLITE_JOURNAL_MODE=deletethere, 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-sidebodystops being mislabeled; a modal action'stargetresolves as a page, not an object; the spec'sdisabledpredicate is honored on every action-rendering surface; inlinelookupaction params get a real record picker. - Forms consume spec-aligned
FormViewbuttons/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
lockRecordinstead 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 specChartConfigauthor shape, its aggregate result-column naming is a contract, and its axis bindings are validated — a fieldlesscountaggregate no longer keys its value columnundefined.ChartAxis.stepSize,ChartConfig.descriptionand.heightare 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
ListViewread the spec-canonical vocabulary (filter,$notContains, type/label/maxLength keys), andsecretstays out of inline edit.
Flows, Studio and Setup
- A paused screen flow is completable:
visibleWhenis 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.searchabletoggle in the object settings panel, and step warnings in the Flow Runs panel; the never-firingrecord-changeoption 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_adminis 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, andenbecomes 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-gl5→6 (theplugin-mapdefault import is dropped to match),chalk5→6, andjsdom29→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_ACTIONSvalve 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_ENABLEDopt-in era ends;OS_ALLOW_LAX_ACTION_PARAMS=1is the escape hatch — plus the D1 evidence gates (os migrate value-shapes, fresh-datastore attestation, released-file collection behind the verified file migration). - ADR-0113 —
requiredbecomes the write contract;storage.notNullowns the column. - ADR-0114 — the closed field-error catalog and the
fieldErrors→fieldsrename (#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 retirements —
api.requireAuth(#3963), the waittimeoutMs/onTimeoutpair (#4158),query.joins/windowFunctions(#4286), thefields[]object form (#4196),query.cursor/distinct(#4286),BatchOptions.validateOnly(#4052) — and #4350's correction re-labelling them protocol 17 (their tombstones said "18", soos migrate metawas 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
enabledis enforced, RLSpriorityand the four inert tool keys are removed. - Flow
errorHandling.maxRetrieshas 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 witherror.details.code: 'VALIDATION_FAILED' | 'FLOW_FAILED'and per-fieldfields[]; 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 andredirectUrlnever firing. Object-less actions canonically key on'global'and thePOST /actions//:actionshape is mounted.client.actions.*callers need no change — the SDK still never throws, and folds every failure into{success: false, error}. POST /actions/:object/:actiondispatches on the declaredtype(#3915). Aflowaction executes via the automation service as the caller —userId/ positions / permissions / tenant forwarded, so arunAs: 'user'flow enforces RLS as the invoker instead of the user-less UNSCOPED path — and the params bag is seeded withrecordId(+ the declaredrecordIdParam);url/modal/form/apiactions answer 400 naming what to call instead of a registry miss. StandalonedefineActionartifacts and Studio-authored action rows now resolve on REST with theirrequiredPermissionsenforced (previously MCP-only). Script action bodies run with a bound, attributedctx.api— owner-scoped writes stop dyingFORBIDDEN, 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) is400 INVALID_FILTER— it used to return the unfiltered table as a clean 200; unknown fields insort/select/expand/searchFields/groupBy/aggregationsare400 INVALID_SORT/INVALID_FIELD(a typo'dselectreturned every column against FLS and data minimisation; an unknowngroupBycollapsed N groups into one;sum(<typo>)folded to0— indistinguishable from a real zero in a report); an unknown bare query parameter is400 INVALID_FIELDwith the canonical suggestion (?pageSize=5used to become a zero-matching filter answeringtotal: 0); a dotted-path sort (?sort=account.name) is rejected with the denormalisation prescription; explicitfilter+ bare field parameters compose with$andinstead of dropping the implicit half; and two sort spellings that silently never applied — the SDK's declaredorderBy: 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/offsetread with noorderBy— the shape every list view without a configured sort sends — is now ordered by the unique key on SQL and MongoDB, and any non-emptyorderBygains 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 growingoffsetwhile writing to the rows they were reading, so rows slid past the cursor unvisited — which meantrebuildApproverIndexdeleted index rows for requests it skipped (an approver silently dropped from someone's queue),verifyFileReferencesreported referenced files as unreferenced, the file and pinyin backfills left rows unconverted under a run that reported success, andscanValueShapes— 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 passbypassTenantAuditbecomes a declaredDriverOptionsmember (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 (
filter→where,select→fields,sort→orderBy,skip→offset,populate→expand) resolve through one spec-owned fold with one precedence — four of five pairs were inverted between readers, so?select=a&fields=banswered[a]on one path and[b]on the other. The fold now covers every engine method:findOne/count/update/delete/aggregatewith{filter}used to match every row —update(data, {filter, multi: true})rewrote the whole table anddelete({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.havingis enforced (#4286 follow-up). Declared since AST v2 and executed by nothing,havingnow filters aggregated rows on both the native-driver path and the in-memory fallback, with$and/$or/$notand 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 with400 VALIDATION_FAILED+fields[]: a garbagePOST /data/:object/querybody used to degrade into an unfiltered full read,POST /automation/:name/toggle {"enable": false}used to enable the flow and answer 200, and a misnamedPOST /notifications/readkey was a 200 no-op./analytics/queryand/analytics/sqlvalidate the bareAnalyticsQueryat the entry — the{cube, query, format}envelope dialect of the retired shim is tombstoned, and the filter field iswhere, notfilters— 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 bybatch.maxBatchSize, 1..1000); a bodyobjectcan no longer move a bulk write or a/data/:object/queryread to a different object than the gated URL object;deleteManybuilds its predicate from the validated id list only — calleroptions.whereused to widen a one-id request into deleting everything the caller could see — and per-id deletes now rundeleteBehaviorcascades and RLS/FLS under the caller (its response becomes the structuredBatchUpdateResponse); and a caller-suppliedcontextis 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 undererror.details, withSETTINGS_VALIDATION.fieldsbecoming ADR-0114FieldError[]. Raw-fetchcallers of these routes add one.datahop and readerror.code; SDK callers are shielded — and the client now normalizes both server envelope families, soerr.codeis 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 structuredconstraint(min/max/actual/…). Match oncodeandconstraint, not English message text; any built-in is overridable per deployment viavalidation.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/runtimeand 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-contractfilterskey — is removed: without@objectstack/service-analyticsthe routes are not mounted (404), and discovery reportsanalytics: unavailableinstead of hardcoding it always-on (#3989, #4010). The dispatcher's dead/storagebridge (which could never complete a request, and whose wildcard shadowed the real presignedservice-storageroutes) 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: truemarker is retired), its remedy strings name real packages (ten of fifteen previously named packages that do not exist), and a slot self-declaringhandlerReady: falseis answered like an empty slot instead of having its fabricated 200 served (#4058, #4089, #4130). HonoServerPluginis a transport adapter (#4073). Its raw data-C+R surface, its third (pre-DiscoverySchema) discovery payload and theregisterStandardEndpointsflag 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-tenantkernel-resolverseam, 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 declareddriver: 'memory'datasource — silently wrote.objectstack/data/memory-driver.jsoninto the working directory and reloaded it on the next boot; thepersistence: 'auto'default drifted from the accepted opt-in design (#815) and is nowfalse. 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 initscaffolds stop namingdriver-memoryas a dependency. - The
artifact-apimetadata 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-configuredartifact-apithrows loudly atstart()instead of silently falling through to filesystem scanning. - Smaller wire corrections: the plural
/meta/:typespellings 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: trueservice marker is retired in favour of__serviceInfo; and exported spec types stop resolving toany(NavigationItem,FormField,QueryAST['fields'], thez.inputsides of the recursive schemas) — authoring code whose invalid shapes previously compiled clean now failstscwith the real error, gated by the newcheck: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()theirconfigbefore executing — a violation refuses the node as a guard naming every violated path;registerFlowrejects undeclared config keys (exact path, declared key set, did-you-mean, per-key tombstones likescreen.visibleIf→visibleWhen) and warns on keys aconfigSchemadoes not declare; declared bare-CEL slots are validated (screen.fields[].visibleWhenauthored 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).
subflowandmapconfig.flow→flowName(#4278, #4045),notify's nestedsource: {object, id}→sourceObject/sourceId, andconnector_action's mis-rooted config lifts ontoconnectorConfig— Studio-authored connector nodes never dispatched; stored flows are healed at load. All protocol-17 windows applied byos migrate metaand 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'*', andbindHooksToEngine'sstrictoption 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
registerFlowrehydration) — a stored action with the removedexecutedispatches viatargetagain; 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 --storedreplays the chain oversys_metadataand 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_OPERATORSmembers (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.saveMetapersists 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
typeerrors instead of silently answeringCOUNT(*)(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/$notcombinators — 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, andReportSchema.orderbecomes a declarable ordering lowered into the dataset selection (#3916). - Temporal correctness lands as one campaign (ADR-0053).
Field.datetimeandField.timeget 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 toDATETIME(3)with UTC-pinned connections (REST datetime writes on MySQL previously failed outright); legacy columns converge at schema sync andos migrate planlists the in-place work with row counts (#3912, #3994, #4047, #3954). A bareYYYY-MM-DDupper 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}' }RLScheckpolicy denied every write made after 00:00 (#3777, #4042). The type-blind evaluators compare a JSDateagainst wire text correctly — fail-closed RLS was denying legitimate SDK writes whose payload carriednew Date()(#4191).dateNOW()defaults resolve UTC on every dialect (#4022). A shared conformance matrix in@objectstack/spec/datais 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_CTX— any 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/sharesverb;listSharesis management-gated and the opensys_record_shareread surface is self-scoped for non-admins; the whole/sharing/rulessurface requires the newmanage_sharingcapability (seeded intoadmin_full_access); anedit-level share no longer confers delete (canDelete= ownership, write depth, ormodifyAllRecords; bulk deletes stop widening through shares); revoke validates the share belongs to the URL's record; grants on objects the sharing gates never consult failSHARING_NOT_ENABLEDinstead 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).
enforceProjectMembershipprobed the auth service through a shape it never had, souserIdstayed 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-securityabsent, dev filled the slots with allow-all / no-RLS / unmasked fakes behind one warn line; the slots now stay empty and fail closed, andplugin-devrefusesNODE_ENV=productionoutright (OS_ALLOW_DEV_PLUGIN=1overrides, and now brands the boot loudly instead of starting silent, #3900). - Imported users skipped field coercion (#4251 sweep). The
/admin/import-usersroute probed a method its service never had, so rows reachedsys_useruncoerced on every deployment. The same typed-slot campaign fixedhandleAuthreaching the (now deleted) mock instead of the real auth service, and/analytics/sqlcrashing 500 on providers withoutgenerateSql.
New capabilities (backend)
- Approvals grow up as a decision surface. A
decisionOutputsentry can declarerequired: 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 structuredreassign_from/reassign_toparties that timelines render without free-text archaeology (#4365); and arunAs: 'system'flow's writes are audited assvc:flow:<flowName>instead of "Unknown user" (#4366). - Run summaries learn honesty about side effects (#4395, #4396).
connector_actionand mutatinghttpnodes reportunmeasuredEffect(the platform cannot know whethercrm.push_opportunitywrites), anunmeasured_countcolumn joinssys_automation_run, and the broken-sweep detector becomesselected > 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:readylanded on a placeholder adapter that silently ignorescronschedules — 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 migratekeeps 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__searchcompanion 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'sonEnable/functionsonto artifact boots so declaredscriptactions 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_ROOTactually takes effect (uploads land under.objectstack/data/uploadsfrom 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 authoredapi.*keys survive the serve-time config merge instead of being replaced wholesale. - Platform infrastructure is composable (#4243, #4270, ADR-0116).
sys_migrationandsys_secretmove toPlatformObjectsPlugin, 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 kernelPlugincontract gains declared ordering (optionalDependencies,requiresServices,providesServices) with boot-time validation that names both plugins, both slots, and the fix;PLATFORM_ALWAYS_ON_CAPABILITIESis a published export, so hosts stop hand-mirroring the always-on slate (cloud's mirror had silently lostsms,messagingandanalytics). 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: truelookups (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
lintUnknownAuthoringKeysreports every silently-stripped object/field key across all sixteen metadata collections — with rename/retirement guidance and nested-metadata descent — throughdefineStack,os validateandos compile; error-severity reference-integrity rules gate flowupdate_record/create_recordwrites to undeclared fields and stalesearchableFieldsdeclarations; hook and action bodies get the same write-set analysis as advisories, plus a runtime report for deadctx.recordwrites (#4345); and a field carrying both per-tenantunique: trueand a global unique index is flagged before the second tenant finds out. All three commands now answer identically by construction —os lintandos compilewere letting through react pages and readonly flow writes thatos validaterejected, including the field resolution that gates (an unresolvable<ListView filters>predicate yields a silently empty list, indistinguishable from no data). Expect CI runningos lintto newly fail on metadata that was already broken. - Service slots are typed contracts (#4251, #4127).
IObjectQLEnginelands (the seven consumer-local stand-in surfaces die),getServiceresolves through a slot→contract ledger, and a shrinking baseline bansany-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.paneplaces sections in split forms explicitly (objectui#2153), andelement:button'sproperties.actiongainsInlineActionSchema— previously authorable only viaas any(objectui#2997). - Package hygiene reaches consumers (#4261, #4248, #4097).
CHANGELOG.mdships 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 initstampsengines: { protocol: '^17' }so new packages join the ADR-0087 load-time handshake; andspec-changes.json, the generated upgrade guide and thespec_changesMCP 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.targetto/api/v1/actions/:object/:action. Against a 17 server — which resolves the declaration bynameand 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,
redirectUrlfinally fires, one source now owns the/actionsenvelope 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
$orderbyreaches 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
defaultValueschange 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; swappingrecordIdno 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-Matchand surface 409 conflicts instead of silently overwriting; a wizard withallowSkipno 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 owncolumns. - 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[].typedraws (and a spec-shapeseriesplots 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,
visibleis evaluated per selected record, params render through the shared form-field widgets (lookup errors get Retry,sys_userparams get the PeoplePicker), and a bulk delete clears the row checkboxes. ThebulkEnabledderivation 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 specdisplayTypegets its own presentation, and the specicon, 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
InlineActionSchemaabove is its contract); the script node's form authors what the executor actually runs; a publishedconfigSchemacan no longer delete a node's sibling-block editors; and the flow inspector preserves sibling blocks.SplitFormhonours the newFormSection.pane. - Types stop forking the spec.
Page/App/Dashboardvalidate the spec's own fields instead of passing them through; navigation metadata stops losing spec fields the renderer already honours; the*Validationfive 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 thepage:*/element:*/record:*block families. - Resilience and chrome. A transient
/me/permissionsor/me/localizationfailure 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: trueto every environment-authored permission set whose holders must keep exporting — package-shipped sets are re-seeded for you, andmember_defaultdeliberately does not carry the grant. apiMethods: runnode 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 appliesexecute→target,conditionalRequired→requiredWhen, the retired-key deletions (os migrate metahandles all of them), sharingfull→edit, 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[]ontoskills; dropstack.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 arequiredparam, breaksoptions/multiple/reference, or carries an undeclared key. The error names the param and the declared list.OS_ALLOW_LAX_ACTION_PARAMS=1buys 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 forOS_DATA_VALUE_SHAPE_STRICT_ENABLEDto 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-shapesand 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 --storedto see whichsys_metadatarows still carry a pre-17 shape, and--applyto 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 exits0. - 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 metadoes 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 pointconfigthere. Also re-check what you wrote underconfig: 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'; dropguestand owner-type rules; expectaccessLevel: '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'ssharingModelif everyone should read it. - RLS policies: delete
priority(os migrate metadoes it; outcomes are unchanged). Re-check any policy you setenabled: falseon — disabling now actually withdraws its grant, which until v17 it silently did not. - Tools: delete
category/permissions/active/builtInfrom tool metadata (os migrate metadoes 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 metaonce more — it also stripsaction.shortcut/bulkEnabled,flow.active/template/nodeoutputSchema/errorHandling.fallbackNodeId, the inert view keys, dashboard/widgetaria/performance,agent.knowledgeandskill.triggerPhrases. If you setflow.active: falseexpecting a flow to stop, setstatus: 'obsolete'— the flag never worked. - Anonymous access: delete
api.requireAuth(os migrate metadoes it). If you relied onrequireAuth: falseto serve something publicly, re-declare it narrowly — a public form view, a share link, orbook.audience: 'public'— and make sure the stack mounts auth at all, or it now fails at boot. - Wait nodes:
os migrate metamoveswaitEventConfig.timeoutMsontotimerDurationand dropsonTimeout. 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,cursoranddistinctfrom queries and the{ field, fields, alias }object form fromfields[]; replaceQueryBuilder.cursor()/.distinct()call sites. Stop sendingoptions.validateOnlyto/batch,/updateManyand/deleteMany— it never previewed anything, it persisted. - Required fields: run
os migrate meta— it stampsstorage: { notNull: true }onto every pre-17required: truefield so your columns keep their exact constraints. New fields:required: truealone write-gates with a nullable column; addstorage.notNullwhen you want the DDL. Audit any client that PATCHesnullinto required fields — that write is now rejected. - Membership: move business capability off
sys_member.roleand 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}andprojects.listTemplates(); repoint marketplace publish toPOST /api/v1/packages/publish; dropos environments create --template. Imports ofIWorkflowService,WorkflowProtocolor theGet/WorkflowState/Config/Transitiontypes no longer resolve — theworkflowslot retired with them (#4451); usestate_machinevalidation rules, approval flow nodes andrecord_changeflows instead. Discovery responses no longer carryservices.workflow/routes.workflow/features.workflow— a reader keying on them saw onlyunavailable/falsebefore, so delete the read. - Console hosts: the same removals land in objectui — drop the
useClientNotificationsdevice/preference delegates (@object-ui/react) and replace the retired@object-ui/typesCapabilities re-exports with imports from@objectstack/spec. If you build the Console from source, note themaplibre-gl5→6 /chalk5→6 major bumps. - Type importers: replace
ObjectStackProtocol/ObjectStackProtocolSchemawith the narrowest per-domain slices; drop GraphQL types and any of the removed dead spec clusters. If you importedMetadataFormat,MetadataStats,MetadataLoadOptions,MetadataSaveOptions,MetadataLoadResult,MetadataSaveResult,MetadataWatchEvent,MetadataCollectionInfoorMetadataLoaderContractfrom@objectstack/spec/kernel, change the path to@objectstack/spec/system— same names, and that copy is the one the runtime has always emitted. (MetadataExportOptions/MetadataImportOptionsmoved 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 shapeMetadataManageractually implements.) It is the looser of the two, so a reader may need new narrowing: notablymetadataType/name/timestampare optional there. (MetadataWatchEvent.typebriefly also declared the raw watcher valuesadd/change/unlink; they had zero producers — both emit sites normalize — and were removed in #4536, so the enum now carries exactlyadded/changed/deleted.) Nothing to migrate at runtime — the values the runtime emits were always these. - Multi-org: the
groupposture requires the enterprise runtime — deployments relying on it self-activating must install@objectstack/organizationsor move toisolated. - 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
datais 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/searchFieldsnames now answer 400 instead of silently over- or under-returning — fix the request the error names, and note that previously-ignoredorderBy: string[]and{field: direction}sorts now actually apply. - Raw-
fetchreaders of the settings / datasource-admin / external-datasource / package / share-link routes: read the payload underdataand errors aserror.code/error.message(#3843, #3983). - Bulk callers: stay under
batch.maxBatchSize(default 200, raisable to 1000) or chunk; stop sendingobject/contextin bulk bodies (ignored); readdeleteMany's new structuredBatchUpdateResponse(#3939, #3897). - Analytics clients: send the bare
AnalyticsQueryshape (where, notfilters; no{cube, query, format}envelope) (#3878). Embedded / programmatic hosts must install@objectstack/service-analytics— the context-less fallback is gone (#3891). - Bare
HonoServerPluginhosts: mount@objectstack/restor 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.jsonfiles (#4065, #4083). artifact-apisources: switch to thelocal-fileURL form oros package install— a configuredartifact-apinow fails the boot (#4246).- Hand-built kernels: compose
PlatformObjectsPluginif you relied on storage/settings registeringsys_migration/sys_secret(#4243, #4270); declare the ADR-0116 ordering fields on plugins that resolve services ininit(). - SQLite operators: back up with
sqlite3 app.db ".backup …"— a bare file copy can miss committed transactions in the WAL sidecars — and setOS_DATABASE_SQLITE_JOURNAL_MODE=deleteon 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-devrefusesNODE_ENV=productionwithoutOS_ALLOW_DEV_PLUGIN=1(ADR-0115). - Sharing admins: add the new
manage_sharingcapability to the permission sets held by whoever administers sharing rules (admin_full_accesscarries it already), and expect edit-level shares to stop conferring delete (ADR-0111). - Flow authors: rename aliased node-config keys (
subflow/mapflow→flowName, notifyto/subject/body/url→recipients/title/message/actionUrl, scriptfunctionName/input→function/inputs) or runos 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).