| 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 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.
- 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.
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.
| 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.
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.
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 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).
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.
| 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.
| 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.
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.configandreadReplicasstay open records: their shape is per-driver and the driver's ownconfigSchemavalidates them. 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: an unregistered capability reads asfalse, 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 (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.
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.
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.
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).
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.
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.
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.
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).
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 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.
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.
| 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 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.
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 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 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: 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.
- 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/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.
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.
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.
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) |
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.
- 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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
The v17 Console window is cf2d56e32a11 → 4a4829d0ef39 (.objectui-sha) — 128
objectui commits on top of the pin 16.1.0 shipped.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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, including ones with no migration behind them. - 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'; 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. - 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. - 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. - 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.
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).