| title | v14.0.0 |
|---|---|
| description | The v14 series completes the ADR-0090 vocabulary convergence, makes object enable.* capability flags real gates, and adds admin user management, phone/SMS auth, book-audience enforcement, a data-lifecycle contract, and effective-dated grants. |
Released July 2026 — current: 14.5.0 (July 13, 2026). All @objectstack/*
packages are version-locked, so one number describes the whole platform. This
page covers the v14 series: it opens with the 14.0.0 breaking change, then
folds in the capabilities that shipped across 14.1 → 14.5.
The v14 line finishes the ADR-0090 permission-model convergence (the last
role/profile vocabulary leftovers), turns the object enable.* capability flags
into real opt-in / opt-out gates, and — across the minors — ships admin-driven
user management, phone and SMS authentication, book-audience enforcement at the
read layer, a data-lifecycle retention contract, and effective-dated grants with
self-service delegation.
TL;DR for upgraders: one type-level breaking rename —
book.audience: { profile } → { permissionSet }. Two behavior changes to know:
members no longer get delete as a baseline right, and enable.feeds /
enable.activities are now default-on opt-out gates (behavior-preserving) while
enable.files is opt-in. Everything else is additive.
ADR-0090 removed the Profile concept, but book.audience (docs-as-metadata,
ADR-0046 §6.7) still modelled its gated arm as a profile reference. Books ship
in packages, and packages own permission sets but never positions — so the gate
is a capability reference, a permission-set name the reader must hold:
// before
defineBook({ name: 'crm_admin_guide', audience: { profile: 'crm_admin' } })
// after
defineBook({ name: 'crm_admin_guide', audience: { permissionSet: 'crm_admin' } })Pre-launch one-step rename, no alias — the zod union rejects { profile } at
parse time. The 'org' and 'public' literals are unchanged ('public' ≡ the
built-in guest position).
The last "role" / "profile" surfaces from ADR-0090 are renamed one-step:
| Before | After |
|---|---|
PortalSchema.profiles: ['client_portal_user'] |
positions: ['client_portal_user'] — portal admission is position-scoped; use guest for anonymous-only portals |
RLSUserContextSchema.role: string | string[] |
positions: string[] — matches the current_user.positions the RLS compiler resolves |
sys_record_share.recipient_type: 'role' |
'position' — no stored-data migration needed (no reader expanded non-user share rows) |
member_default carried allowDelete on its '*' grant. That bit is dropped:
members keep create / read / edit-own, and deleting records is no longer a
baseline right. Grant deletes per object via an ordinary position-distributed
set. (This landed alongside calibrating describeHighPrivilegeBits to the exact
ADR-0090 D5 bit list so the baseline could finally bind to the everyone
anchor — "what new users get" is now literally "what is bound to everyone".)
The ObjectCapabilities flags were parsed but mostly unconsumed. Each now has a
defined enforcement contract — ObjectCapabilities is 100% live:
enable.activities— opt-OUT (default flipsfalse → true). CRUD keeps mirroring into thesys_activitytimeline unless the object declaresactivities: false. Behavior-preserving; the compliancesys_audit_logrow is never gated.enable.feeds— opt-OUT with server-side enforcement (default flipsfalse → true). An explicitfeeds: falserejectssys_commentcreation on that object at the engine hook (403FEEDS_DISABLED, fail-closed).enable.trackHistory— reclassified live (the console has gated the record History tab on it since 2026-05): object flag = History-tab master switch, per-field flag = diff selector; audit capture stays unconditional.enable.files— opt-IN (default staysfalse). The generic record Attachments panel is a new surface; asys_attachmentjoin row may only target an object that declaresenable: { files: true }(else 403FILES_DISABLED).Field.file/Field.imageare independent and work regardless of this flag.
A new position approver type — { type: 'position', value: '<name>' } expands
to the position's holders via sys_user_position. Keep type: 'org_membership_level'
only for membership tiers (owner/admin/member); the department approver type is now
honored by its spec spelling. New lint rules (approval-approver-not-membership-tier,
approval-approver-type-unknown, approval-escalation-reassign-no-target) catch
the mismatches, and SLA escalation escalateTo is documented position-first
(14.1.0).
sys_user is managed by better-auth and its generic CRUD is suppressed, so the
only way to add a teammate used to be the email invite flow. Three staged
capabilities land:
- Admin direct user management —
POST /api/v1/auth/admin/create-userand a wrappedset-user-password(platform-admin gate; real better-auth pipeline). Optional generated one-time password (returned once, never persisted) and asys_user.must_change_passwordflag enforced through the auth gate (403PASSWORD_EXPIRED). - Phone sign-in (opt-in
auth.plugins.phoneNumber) — phone+password viaPOST /sign-in/phone-number. Addssys_user.phone_number(unique) /phone_number_verified; phone-only accounts get an undeliverable placeholder email, never derived from the number. - Identity bulk import —
POST /api/v1/auth/admin/import-users(rows/csv/xlsx, dryRun, upsert by email or phone) writes every row through better-auth with aninviteortemporarypassword policy. Sync, ≤500 rows, no undo; upsert never resets an existing user's password.
The platform had no SMS delivery; this adds it end to end:
- New
@objectstack/service-sms—ISmsService/ISmsTransportcontracts with Aliyun SMS and Twilio transports plus a dev log fallback, configured through a newsmssettings namespace. Deliberately no message persistence and no body logging (SMS bodies carry OTP codes). - Messaging
smschannel —notify(channels: ['sms'])resolvessys_user.phone_number, renders(topic, 'sms', locale)templates, and inherits outbox retry / dead-letter. - Phone OTP flows — sign-in/verification and self-service password reset, with abuse hardening (explicit attempt cap, per-number cooldown + rolling-hour cap shared across nodes, per-IP rate-limit rules). OTP codes never reach logs.
- SMS invites — the import
invitepolicy supports phone-only rows.
/meta/book, /meta/doc, and /meta/book/:name/tree now enforce the
audience model: anonymous callers see only public books/docs; a
{ permissionSet }-gated book requires the caller to hold the named set; a
doc's effective audience is the union over the books that claim it. Gated
evaluation fails closed when holdings cannot be resolved.
Platform-generated data is now bounded by construction. A new lifecycle object
property (class: record | audit | telemetry | transient | event plus
retention / ttl / storage(rotation) / archive) is enforced by a
platform-owned LifecycleService (default-on; disable via
OS_LIFECYCLE_DISABLED=1). The Reaper batch-deletes rows past their window
and reclaims space; rotation time-shards a table so expiry is an O(1)
DROP; the Archiver does retain → archive → delete for audit objects once a
telemetry datasource is registered. 11 platform objects now carry
lifecycle declarations (e.g. sys_activity 14d rotated, sys_audit_log 90d
hot → archive), tunable per environment/tenant via a new lifecycle settings
namespace.
- Grant validity windows (L1) —
sys_user_positionandsys_user_permission_setgainvalid_from/valid_until(half-open UTC; null = unbounded), plusreason/delegated_from/ certification columns.resolveAuthzContextfilters both grant tables through the sharedisGrantActivepredicate, fail-closed — an expiredadmin_full_accessgrant no longer derivesplatform_admin. Explain surfaces the "held until … — expired" contributor state, so "why did access disappear" is self-answering. No background job: an expired grant simply stops resolving. - Delegation of duty / 职务代理 (L2) —
PositionSchema.delegatablelets a non-admin holder self-service delegate their own position to a delegate for a bounded window (mandatory futurevalid_untilwithin a 30-day ceiling, mandatoryreason, insert-only so it is not self-renewable). Administration is never self-delegated — adelegatableposition that distributes anadminScopeset is rejected.
RecordRelatedListProps.relationshipValueField (the generic affordance for
name-keyed junctions) powers a Positions tab on sys_user and a new
sys_position detail page (Holders / Permission Sets tabs) with zero
bespoke UI. GET /me/apps now sources the engine registry (the authority the
meta routes use) so tabPermissions and AppSchema.requiredPermissions have a
real enforced consumer.
The agent principal goes live. An MCP request authenticated with an OAuth access
token now resolves as an AI agent acting on behalf of the human sub, and
its effective permission is the intersection of a scope-derived capability
ceiling and the user's own grants — never more than either:
data:read→ read-only,data:write→ full CRUD, neither → no data access.userIdstays the human, so owner-stamping andcurrent_user.*RLS still resolve to them; the user's ownsystemPermissionsare cleared so a capability-gated action can't ride the user's capabilities.- A mis-resolved agent falls back to the restricted (no-object-access) set,
so it fails closed, never open. A token without an authorized client stays
an ordinary
humanprincipal. - Capability-gated business actions are delegated only when the token
carries the
actions:executescope — the user's explicit consent to let the agent act on their behalf. Data reach is unaffected: an action's reads/writes still flow through the CRUD/FLS/RLS ceiling ∩ user intersection.
Security tightening. Previously an OAuth request executed with the logged-in
user's full authority and scopes narrowed only the tool surface; now the scope
is also a real data-layer ceiling, so a data:read token can never write any
record no matter what the user could do.
The lifecycle contract that landed in 14.4 is now the single owner of platform data bounding:
- Per-plugin retention sweepers retired —
JobRunRetention/NotificationRetentionand theretentionDays/retentionSweepMsoptions onJobServicePlugin/MessagingServicePluginare removed (breaking, shipped as minor per the launch-window convention). The platform LifecycleService enforces the same windows from thelifecycledeclarations (sys_job_run30d, notifications 90d), tunable via thelifecyclesettings namespace (retention_overrides, tenant-scoped). retention.onlyWhenstatus predicate — a mixed table can scope its age reap to a row filter, sosys_automation_runreaps onlycompleted/failedruns and never strands apaused, in-flight approval. Rejected when combined with rotationstorageorarchive.- Telemetry datasource split —
objectstack devprovisions a dedicatedtelemetrydatasource (<primary>.telemetry.db) so lifecycle-classed system data stops sharing the business dev DB (OS_TELEMETRY_DB=0opts out,=<path>opts in anywhere). Newos db cleanruns the one-timeVACUUMthat lets legacy files adoptauto_vacuum=INCREMENTALand reports reclaimed bytes. - Studio — the object metadata form now exposes the
lifecycleblock (class- retention / TTL / rotation / archive / reclaim).
- FLS keys must be object-qualified (14.4.0) — the evaluator matches
field-permission keys by
<object>.<field>, so a barebudgetkey matched nothing and the declared masking silently never enforced. A newsecurity-fls-unqualified-keylint rejects bare keys at compile time with a fix-it. PROTOCOL_VERSIONauto-sync at version time (14.4.0) —changeset versionnow regenerates the handshake constant from the spec major, closing the drift class that reddenedmainafter the 14.0.0 release (release PRs opened by the changesets action never trigger CI, so the lockstep guard could only fire after a merge).externalSharingModelhonestly marked (14.4.0) — the dial has no runtime consumer yet (authoring lint + Studio badges only); its liveness entry moves toplanned+authorWarn, and the docs say external-principal evaluation lands with the principal-taxonomy phase.- Settings routes no longer trust identity headers (14.5.0, Critical, #2848) —
GET/PUT/POST /api/settings/*derived the caller from spoofablex-user-id/x-tenant-id/x-permissionsheaders andsetManyran no permission check, so on a defaultos serve --serverdeployment an unauthenticated client could write tenant- or platform-scoped settings and enumerate every namespace. Identity now comes from the verified resolution (resolveAuthzContext), manifestreadPermission/writePermissionare enforced at the HTTP boundary, and the missingsetup.writecapability is now declared and granted toadmin_full_access/organization_admin. In-process/boot callers are unchanged. - Share-link routes hardened (14.5.0, #2851) — the raw-app
POST/GET/DELETE /api/v1/share-linksroutes trustedx-user-id/x-tenant-idheaders and ignored the caller on revoke, so an attacker could forge link attribution, enumerate another user's tokens (?createdBy=<victim>), and revoke anyone's links. Identity is now verified; list is forced to the caller's own links; revoke requires the link's creator; andcreateLinkchecks the record is visible to the caller first. The public/:token/resolveroute stays public. - Analytics queries scoped to the caller (14.5.0, #2852) —
handleAnalyticsdropped the execution context, so the per-object read-scope provider ran with no tenant/RLS filter and a caller could read rows their row-level security hides. The execution context is now threaded intoanalyticsService.query/generateSql, scoping every object by its read filter.
The Console bundled across the 14.x line (objectui 397dcad7..839536b1) carries:
- Position assignment panels —
relationshipValueFieldrenderer support (aligned to 14.3) renders thesys_userPositions tab and thesys_positionHolders / Permission Sets tabs; older renderers degrade to read-only lists. - Phone-number auth — SMS one-time-code login and password reset (framework#2780 frontend).
- Object Capabilities section in the Settings panel (the
enable.*gates), and an effective-holders list for permission sets with a more discoverable explain user-picker. - Dynamic runtime branding consumed across the console, a cloud AI model-status admin panel, and spreadsheet attachments that reach the build agent.
- Edit as the primary CTA (14.5) — record detail leads with Edit, and you
enter inline edit by double-clicking a field (objectui #2401 / #2402); the
affordance honors
userActions.editon managed objects instead of blanket-disabling fields (ADR-0092 D4). - Permission sets — Studio designs, Setup assigns (14.5, ADR-0056, objectui #2403) — the two apps split the design vs. assignment surfaces.
The record Attachments panel and the "why can this user access?" explain panel landed in the console bundled during the 13.x line — see v13.
For exhaustive, per-commit frontend detail, see the objectui CHANGELOG.
- Update all
@objectstack/*dependencies to^14.0.0(version-locked). - Rename
book.audience: { profile }→{ permissionSet }, and migrate anyPortalSchema.profiles/RLSUserContextSchema.roleauthoring topositions. - Re-grant delete per object where members previously relied on the baseline right.
- Set
enable: { feeds: false }/activities: falseon any object that should stay off (the defaults are now on); setenable: { files: true }to turn on the Attachments panel. - Object-qualify any field-permission (FLS) keys —
os compilenow rejects bare keys. - Optionally adopt the
lifecyclecontract on high-volume custom objects and thesmsnamespace +auth.plugins.phoneNumberfor phone auth. - (14.5) Drop any
retentionDays/retentionSweepMsoptions onJobServicePlugin/MessagingServicePlugin(removed) — retention is now enforced fromlifecycledeclarations; tune it via thelifecyclesettings namespace (retention_overrides). - (14.5) MCP OAuth integrations: an agent no longer inherits the user's full
authority — confirm each client's token scopes (
data:read/data:write/actions:execute) grant the intended ceiling, and addactions:executewhere an agent must run capability-gated actions. - (14.5) Any custom integration hitting
/api/settings/*or/api/v1/share-linksmust present a verified session / API-key / OAuth credential (the header-trusted default is removed); grantsetup.writeto principals that write branding / company / localization / feature-flag namespaces.