Skip to content

Latest commit

 

History

History
1433 lines (1249 loc) · 85.6 KB

File metadata and controls

1433 lines (1249 loc) · 85.6 KB
title v17.0.0
description Files become platform records with governed download, bulk export becomes its own opt-in privilege, the SDK reaches every route the server actually mounts, approvals route approvers dynamically, and a boot that cannot reach its datasource stops pretending it can. Backend and Console notes for 17.0.0.

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

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

Highlights — 17.0.0

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

Breaking changes & migration

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

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

nvm install 22 && nvm use 22

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

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

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

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

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

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

FROM (legacy) TO (primitives) why
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.mjs

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

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

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

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

Three changes land together:

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

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

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

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

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

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 before

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

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

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

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

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

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

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

Removed Use instead Value shape
action.execute action.target unchanged
field.conditionalRequired field.requiredWhen unchanged
agent.knowledge.topics agent.knowledge.sources → the whole knowledge block was later removed (#3896 close-out — see the dead-cluster table)

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

os migrate meta --from <your current major>

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

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

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

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

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

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

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

Node type(s) 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 objectobjectName and warned at run time) is deleted — the conversion layer is now the single seam that declares, converts, and retires flow-config aliases.

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

Authorable schemas reject unknown keys (#4001)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Now, in three layers:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The GraphQL surface is removed (#2462)

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

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

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

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

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

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

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

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

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

unique materializes per tenant (#3696)

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

Aggregation result shapes (#3839, #3849)

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

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

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

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

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

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

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

Smaller breaking changes

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

New capabilities in 17.0.0

Files become platform records (ADR-0104)

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

For media fields specifically:

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

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

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

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

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

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

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

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

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

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

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

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

Approvals: dynamic approver routing (#3447)

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

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

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

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

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

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

Analytics correctness

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

Automation & flows

  • record-after-write fires one flow on create or update (#3427), with previous bound as null on the create leg so start conditions can discriminate.
  • Opt-in single-hop lookup expansion for record-change flow templates.
  • The resume gate is one chokepoint: it follows map: too, is gated by the node the run is parked on, and the route stops accepting engine-internal variables.
  • A fault edge must not switch off a guardrail; refuse-to-execute guards lose their default-routable footgun; a filter that loses a condition must not run; array-form triggerType fails loudly instead of silently never firing; string templates serialize object tokens readably instead of [object Object].
  • retryPolicy / timeout authored on a job are honoured by the scheduler.
  • {filter-token} placeholders evaluate server-side, and the {current_user_id} vocabulary is frozen with unresolvable placeholders failing the build.

Historical data import

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

Lint & CLI

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

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

Spec, kernel & platform

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

New in Console (Studio) — bundled objectui 17.0

The v17 Console window is cf2d56e32a11 → 4a4829d0ef39 (.objectui-sha) — 128 objectui commits on top of the pin 16.1.0 shipped.

Files, actions and forms

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

Approvals

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

Data, grids and charts

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

Flows, Studio and Setup

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

Internationalization & quality

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

Upgrade checklist

17.0.0

  • Node: move to Node 22+ (and pin CI to 22).
  • Export: add allowExport: true to every environment-authored permission set whose holders must keep exporting — package-shipped sets are re-seeded for you, and member_default deliberately does not carry the grant.
  • apiMethods: run node scripts/codemod/apimethods-legacy-to-primitives.mjs, replace legacy values with their primitives, and delete the key entirely if all six remain. Watch for a legacy-only whitelist stripping to deny-all.
  • Metadata renames: run os migrate meta --from <your current major> — it applies executetarget, conditionalRequiredrequiredWhen, the retired-key deletions (os migrate meta handles all of them), sharing fulledit, and every earlier step you skipped, in one pass.
  • Flows: declare runAs: 'system' on any flow that reacts to system writes and must act beyond one user's grants; otherwise ensure the trigger supplies a user.
  • Agents: move anything declared in agent.tools[] onto skills; drop stack.agents (the runtime has never loaded them).
  • Auth: plan for the better-auth 1.7 account-identity backfill — check the boot log for federated accounts whose IdP is no longer registered, and stamp or remove those rows.
  • Actions: check any programmatic caller that posts params — a bag the server used to accept silently now 400s if it misses a required param, breaks options/multiple/reference, or carries an undeclared key. The error names the param and the declared list. OS_ALLOW_LAX_ACTION_PARAMS=1 buys time for an integration you cannot reach today.
  • Files: run os migrate files-to-references (dry run first, then --apply) and reconcile — passing its self-check is what turns on strict media value shapes and released-file collection for this deployment, so read the report before you --apply. Do not reach for OS_DATA_VALUE_SHAPE_STRICT_ENABLED to get there: it opts every value class in at once, regardless of which migrations this deployment has actually run.
  • Reference and structured-JSON values: run os migrate value-shapes and fix what it reports before --apply. It converts nothing — the values it names are application data — and a scan that was truncated or could not read an object fails the gate even at zero violations.
  • 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.
  • Sharing rules: rewrite sharedWith.type: 'group''team'; drop guest and owner-type rules; expect accessLevel: 'full' to convert to 'edit'. A rule must state its criteria — authoring one without is rejected, and a stored criteria-less rule stops granting (its materialised grants are revoked on the next reconcile). State the predicate, or use the object's sharingModel if everyone should read it.
  • RLS policies: delete priority (os migrate meta does it; outcomes are unchanged). Re-check any policy you set enabled: false on — disabling now actually withdraws its grant, which until v17 it silently did not.
  • Tools: delete category / permissions / active / builtIn from tool metadata (os migrate meta does it; none ever had an effect). To gate a tool, gate the underlying action (action.requiredPermissions); to withdraw one, remove it from the skills/agents that reference it.
  • Close-out sweep: run os migrate meta once more — it also strips action.shortcut/bulkEnabled, flow.active/template/node outputSchema/errorHandling.fallbackNodeId, the inert view keys, dashboard/widget aria/performance, agent.knowledge and skill.triggerPhrases. If you set flow.active: false expecting a flow to stop, set status: 'obsolete' — the flag never worked.
  • Required fields: run os migrate meta — it stamps storage: { notNull: true } onto every pre-17 required: true field so your columns keep their exact constraints. New fields: required: true alone write-gates with a nullable column; add storage.notNull when you want the DDL. Audit any client that PATCHes null into required fields — that write is now rejected.
  • Membership: move business capability off sys_member.role and onto positions (sys_user_position).
  • SDK callers: remove calls to client.permissions.*, client.realtime.*, client.workflow.*, client.views.* CRUD, the notifications device/preference helpers, client.ai.{nlq,suggest,insights} and projects.listTemplates(); repoint marketplace publish to POST /api/v1/packages/publish; drop os environments create --template.
  • Console hosts: the same removals land in objectui — drop the useClientNotifications device/preference delegates (@object-ui/react) and replace the retired @object-ui/types Capabilities re-exports with imports from @objectstack/spec. If you build the Console from source, note the maplibre-gl 5→6 / chalk 5→6 major bumps.
  • Type importers: replace ObjectStackProtocol / ObjectStackProtocolSchema with the narrowest per-domain slices; drop GraphQL types and any of the removed dead spec clusters.
  • Multi-org: the group posture requires the enterprise runtime — deployments relying on it self-activating must install @objectstack/organizations or move to isolated.
  • Approvals readers: anything that listed requests tenant-wide must now be a participant or hold the admin override.

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).