@objectstack/spec@16.0.0
Major Changes
-
6c270a6: BREAKING: remove the deprecated
ctx.session.tenantId/ctx.user.tenantIdalias from the hook & action authoring surface — converge onorganizationId(#3290).#3280 made
organizationIdthe blessed developer-facing name for the caller's active org across the JS authoring surface and kepttenantIdas a@deprecatedalias carrying the identical value. That alias is now removed from the hookctx.session, the action-bodyctx.session, and the action-bodyctx.user. Read the caller's active org under the single blessed name:- const org = ctx.session.tenantId; // hook or action body + const org = ctx.user?.organizationId ?? ctx.session?.organizationId;
FROM → TO migration (in any
*.hook.ts/*.action.tsbody):ctx.session.tenantId→ctx.session.organizationIdctx.user.tenantId(action body) →ctx.user.organizationId
The value is unchanged —
organizationIdis the same active-org id, matching theorganization_idcolumn andcurrent_user.organizationIdin RLS/sharing.ctx.userisundefinedfor system / unauthenticated writes, so readctx.session?.organizationIdwhen a hook or action must work regardless of a resolved user.What changed internally:
@objectstack/spec—HookContextSchema.sessiondrops thetenantIdfield (onlyorganizationIdremains). A straytenantIdon a constructed session is now stripped by the schema.@objectstack/objectql— the engine'sbuildSession()no longer emitssession.tenantId; the audit-stamp plugin sources thetenant_idcolumn fromsession.organizationId.@objectstack/runtime—buildActionSession()and the REST actionctx.userno longer emittenantId.@objectstack/trigger-record-change— readssession.organizationId(wassession.tenantId) when forwarding the writer's org to arunAs:'user'flow; behavior is identical.
Explicit non-goal (unchanged): the generic driver-layer tenancy abstraction is not touched —
ExecutionContext.tenantId,DriverOptions.tenantId,SqlDriver.applyTenantScope/TenancyConfig.tenantField, andExecutionLog.tenantId. That isolation column is configurable and legitimately carries an environment id in database-per-tenant kernels; it is a distinct axis from the developer-facing org. The build-timecheck:org-identifierguard now also coverspackages/**to keep reference bodies off the removed name.
Minor Changes
-
f972574: feat(spec):
ActionParamSchemagains optional widget config —multiple,accept,maxSizeThe console now renders action params through the same field-widget renderer
the record form uses (objectui#2700, objectui ADR-0059), so inline params can
declare the widget config the form widgets consume:multiple(array value
shape, mirrorsFieldSchema.multiple), and the upload constraintsaccept
(MIME types / extensions) andmaxSize(bytes) forfile/imageparams.
Field-backed params ({ field }) keep inheriting these from the referenced
field at runtime; inline values override. Purely additive — no existing
schema changes shape. -
6289ec3: feat(i18n): translation slot for action
resultDialogcopy — the one-shot secret-reveal dialogs are now localizableThe post-success
resultDialog(temporary passwords, 2FA backup codes, OAuth
client secrets) had no slot in the translation protocol, so its title /
description / acknowledge button / field labels always rendered the hardcoded
English metadata literals even on fully-translated locales.- spec.
_actions.<action>(object + object-first node) and
globalActions.<action>gain an optionalresultDialogtranslation node
(ActionResultDialogTranslationSchema):title,description,
acknowledge, andfieldskeyed by the literal result-field path
(e.g."user.email"— keys may contain dots; resolvers index the record
directly, never split on.). NewresolveActionResultDialogoverlay
resolver, wired intotranslateActionfor API-boundary translation. - cli.
os i18n extractemits the newresultDialog.*keys (title /
description / acknowledge /fields.<path>for labelled fields), so
coverage and skeleton generation see them. - platform-objects. en / zh-CN / ja-JP / es-ES bundles ship the
resultDialog copy for all six shipped dialogs:sys_user.create_user,
sys_user.set_user_password,sys_two_factor.enable_two_factor,
sys_two_factor.regenerate_backup_codes,
sys_oauth_application.create_oauth_application, and
sys_oauth_application.rotate_client_secret.
Client-side rendering lands in objectui (
actionResultDialogresolver in
@object-ui/i18n+ result-dialog handlers). Purely additive — untranslated
locales keep falling back to the metadata literals. - spec.
-
8efa395: feat(approvals): server-computed
viewercapability for precise decision-action gatinggetRequest/listRequestsnow attach a per-viewer block —
viewer: { can_act, is_submitter }— computed from the caller's context
(ApprovalRequestRow.viewer):can_act— the caller is a current pending approver (their user id is in the
request's resolvedpending_approverswhile it is stillpending). This is
the same check the decision methods authorize with, so it already reflects
position/team/manager resolution — strictly more accurate than a client-side
identity guess.is_submitter— the caller submitted the request.
The declared decision actions on
sys_approval_requestnow gate on it: approver
actions (approve/reject/reassign/send-back/request-info) use
record.viewer.can_act; submitter levers (remind/recall/resubmit) use
record.viewer.is_submitter. Previously approver actions only trimmed the
non-pending case, so a submitter viewing their own pending request saw buttons
they couldn't use (the backend 403'd); a position-addressed approver could be
wrongly hidden by the old client heuristic. Wherevieweris absent (a row
surfaced outside a service read with a user context), the predicate fails closed. -
3a18b60: feat(approvals): rename the
roleapprover type toorg_membership_level(#3133)ApproverType.rolewas the last platform surface projecting the reserved word
"role" (ADR-0090 D3). It is not covered by D3's better-auth exception: that
exception protects better-auth's ownsys_member.rolecolumn, which we do
not own —ApproverTypeis our own enum, an authoring surface, and D3 mandates
that the projection of that concept is spelledorg_membership_leveland
labelled "organization membership", never "role".The sentence licensing the leak was also false: ADR-0090 D3 claims
sys_member.roleis "already relabelledorg_membership_levelin the platform
projection", butorg_membership_levelexisted nowhere in the codebase and
ADR-0057 D7 lists that relabel under "Deferred (evidence-gated, P4)". The
projection never landed, so the word reached authors.The name manufactured a real, silent failure — "hotcrm class": every other
surface renamed toposition(sys_role,ShareRecipientType.role,
ctx.roles[]), so{ type: 'role', value: 'sales_manager' }reads as the
legacy spelling of a position. It resolves against the membership tier, finds
no member row, falls back to an inertrole:sales_managerliteral, and the
request waits forever on an approver that cannot exist.- spec:
ApproverTypegainsorg_membership_level;rolestays as a
deprecated alias for one window (a published 15.x flow keeps loading) with
DEPRECATED_APPROVER_TYPES+canonicalApproverType()as the single source
for the mapping. Removed in the next major. - plugin-approvals: resolves on the canonical type and warns on the
deprecated spelling. Thetype:valuefallback literal keeps the authored
spelling — storedsys_approval_approverrows andpending_approversslots
from 15.x carryrole:<v>, and rewriting it would orphan them. - lint:
approval-role-not-membership-tier→approval-approver-not-membership-tier
(the rule id carried the reserved word too), plus a new
approval-approver-type-deprecated. The two are mutually exclusive: a bad
value wins, because prescribingorg_membership_levelfor a position name
would be wrong advice — the fix there isposition.
Authoring
type: 'role'keeps working and now says so out loud. Rewrite it as
org_membership_level; if the value is an org position, the fix isposition. - spec:
-
43a3efb: fix(rest): gate the cross-object transactional batch by the same per-object API rules as single-record writes (#1604)
The
POST {basePath}/batchroute (issue #1604 / ADR-0034) wraps N cross-object
create/update/delete ops in one engine transaction, but it skipped the
per-object API-exposure gate every single-record route applies — an
authenticated caller could write to anapiEnabled: falseobject, or run an
operation outside an object'sapiMethodswhitelist, straight through the batch
surface (ADR-0049 / #1889 — the same "declared ≠ enforced" hole closed for the
generic write path in #3220 / #3213).The route now:
- validates the body against a new
CrossObjectBatchRequestSchema
(@objectstack/spec/api, Zod-First) — a malformed op, an unknown action, or a
missingobjectis a400instead of a500; - enforces
enable.apiEnabled/enable.apiMethodsfor every op (metadata
fetched once, each distinct(object, action)checked) BEFORE opening the
transaction —404 OBJECT_API_DISABLED/405 OBJECT_API_METHOD_NOT_ALLOWED; - requires an
idforupdate/delete(400); - rejects an unresolvable
{ $ref }with400 BATCH_UNRESOLVED_REFinstead of
silently writing anullFK; - rejects an explicit
atomic: false(400 BATCH_NOT_ATOMIC) rather than
silently applying atomically — non-atomic per-object batches stay on
POST /data/:object/batch.
enforceApiAccessis refactored to share the pureapiAccessDenialFromEnable
check + aloadObjectItemshelper with the batch route (single-record behavior
unchanged). Addsrest-batch-endpoint.test.ts— the REST-boundary coverage
ADR-0034 flagged as missing (commit,$ref, rollback surfacing, API-access
denial, request validation). - validates the body against a new
-
524696a: feat(spec)!:
DashboardWidgetSchema.strict()— reject undeclared widget keys (framework#3251)The ADR-0021 analytics endpoint.
DashboardWidgetSchemanow rejects any
undeclared top-level key instead of silently stripping it, moving a whole class
of author error (a hallucinated or legacy key that renders as a silent no-op)
from fallible human review to deterministic CI.options: z.unknown()remains
the escape hatch for renderer-specific extras.A custom error map names the offending key(s) and, when a key is a removed
pre-ADR-0021 inline-analytics key (object/categoryField/valueField/
aggregate, pivotrowField/columnField) or an objectui-internal prop
(component, inlinedata), points the author at the dataset shape
(dataset+dimensions+values).Recorded as protocol-16 migration
step16
(dashboard-widget-strict-unknown-keys), mirroring protocol-15'sstep15
strict flip on the form/page schemas (ADR-0089 D3a). The inline-analytics shape
itself was already removed at protocol 9 (single-form cutover), so there is no
mechanical rewrite — the residue is the strictness, delegated to the author.Breaking: shipped as
minorper the launch-window policy (a breaking change
does not burn a major while the stack is in lockstep), riding the already-pending
16.0.0 train. The release train's Version-Packages PR must set
PROTOCOL_VERSION = '16.0.0'; until thenstep16is inert
(composeMigrationChaincaps atPROTOCOL_MAJOR).@objectstack/lint— thewidget-legacy-analytics-shape/
widget-legacy-analytics-unrenderablerules are retained as the friendly,
suppressible bridge on the raw-config lint/doctor paths (strict preempts them on
the schema-parsed compile/validate paths); doc comment updated to explain the
interplay. -
bfa3c3f: Broadcast a
transactionalBatchcapability bit in discovery so clients negotiate the atomic cross-object batch declaratively, instead of runtime-probing 404/405/501 (#3298).The atomic cross-object batch endpoint (
POST {basePath}/batch, #1604 / ADR-0034 item 4) and its typed SDK surface (client.data.batchTransaction, #3271) already shipped, but discovery never told a client whether a backend actually supports it. Consumers (notably ObjectUI'sObjectStackAdapter) had to probe: fire a/batch, read404/405(no route) or501(no runtime transaction), and only then fall back to non-atomic client-side simulation. That is "find out by calling", not capability negotiation — it cannot be decided at connect time and cannot serve as the "minimum backend supports/batch" gate that blocks hard-deleting the non-atomic fallback downstream.WellKnownCapabilitiesSchemagains a requiredtransactionalBatch: boolean, and every discovery producer fills it honestly (declared === enforced), so it never becomes a declared-but-unpopulated bit:@objectstack/metadata-protocol(getDiscovery) — reports whether the runtime engine can honour a transaction (typeof engine.transaction === 'function'). The/batchhandler runs its ops insideengine.transaction(), which degrades to a non-atomic passthrough (or 501) without one.@objectstack/rest(/discovery) — ANDs the engine signal with whether it actually mounts the route (api.enableBatch), so a server with batch disabled reportsfalseeven on a transaction-capable engine (never advertise an endpoint that would 404).@objectstack/plugin-hono-server(standalone discovery) — reportsfalse: this minimal surface registers CRUD only and does not mount/batch(that ships with@objectstack/rest). Under-reporting is the safe direction — a client keeps its correct-but-slower fallback rather than losing atomicity.@objectstack/client— already normalizes hierarchicalcapabilitiesto flat booleans, soclient.capabilities.transactionalBatchis exposed (and now typed) for declarative consumers.
The bit follows the existing capability semantics:
true⟺ the/batchroute is mounted and the runtime can honour a transaction — the exact condition under which the endpoint returns200rather than404/405/501. Additive and behavior-preserving; only the discovery payload gains a field. -
62a2117: Split the overloaded
managedBy: 'system'bucket with an explicitengine-ownedvalue (ADR-0103 addendum, #3343). ADR-0103 deferred the enum split ("revisitable later as a rename") because a newmanagedByvalue would fall through to the fully-editableplatformdefault on deployed Console clients. Both reasons against it are now retired — the server-side write guard /apiMethodsreconciliation //me/permissionsclamp make that fallthrough cosmetic (the write is rejected regardless of what the client renders), and objectui#2712 closed the UI union — so v16 lands it, additively.- New enum value
engine-ownedwith the same all-locked default affordance row assystem(create/import/edit/delete: false,exportCsv: true). It joinsENGINE_OWNED_BUCKETS(the engine write guard) andGUARDED_WRITE_BUCKETS(the/me/permissionsclamp); the guard,reconcileManagedApiMethods, and the clamp mechanisms are unchanged —engine-ownedis an explicit member of the set they already covered by resolved affordance. - 20 objects relabelled
system → engine-owned— the ones the engine owns end to end and that declared no write-openinguserActions(the metadata store, jobs, approval runtime rows, sharing rows,sys_automation_run, the messaging delivery/receipt pipeline,sys_secret, settings). One-line, behaviour-identical per object. - 8 admin/user-writable objects keep
managedBy: 'system'(the RBAC link tables,sys_user_preference,sys_approval_delegation, the messaging config grids) —systemnow reads as "engine-managed schema, writable viauserActions".
Behaviour-, enforcement- and wire-identical: resolved affordances, the guard verdict, the 405
apiMethodsreconciliation, and the permissions clamp are the same before and after — this is a self-documenting relabel, not a policy change. No data migration (managedByis schema metadata) and no code branches on the'system'literal. Retiring the overloadedsystementirely (moving the 8 writable objects to a dedicated bucket) is a breaking rename deferred to v17. - New enum value
-
fefcd54: fix(spec): declare
ownershipas a first-class ObjectSchema field (#3175)The object-level record-ownership model —
ownership: 'user' | 'org' | 'none',
which drives the registry'sowner_idauto-provisioning (applySystemFields) —
was read by the engine via(schema as any).ownershipwhileObjectSchema.create()
rejected it as an unknown top-level key (ADR-0032 / #1535). So a tested engine
opt-out (ownership: 'org' | 'none'on catalog / junction tables) could not be
set through the sanctioned authoring path, and the sameownershipword was read
elsewhere as the unrelated package-contribution kind (own/extend).- spec:
ObjectSchemanow declaresownership: z.enum(['user','org','none']).optional().
Authoring the record-ownership opt-out validates cleanly; the registry reads it
off the typed schema (noas any). A retiredownership: 'own'/'extend'
value fails with guidance pointing at the record-ownership model and noting that
own/extendis the contribution kind (registerObject), not an object-schema value. - cli: the
objectscaffold no longer emits the now-invalidownership: 'own'
(owner injection is the default), andobjectstack infolabels the record model
with the correctuserdefault.
No runtime behavior change:
applySystemFieldsand itsowner_idinjection logic
are unchanged — this makes the property the engine already honors legally authorable
and consistently typed. - spec:
-
369eb6e: refactor(spec): remove unenforced agent
visibilityfield (ADR-0056 D8, #1901)The agent
visibility(global/organization/private) field is removed
fromAgentSchema. It was never enforced: the chat-access evaluator excluded it
and the agent list route did not filter by it, so settingprivatenever hid an
agent. Per ADR-0049 / ADR-0056 D8 ("design+enforce or remove"), a security-shaped
field with no runtime consumer is a liability — authors who setprivatebelieve
they've restricted an agent when they have not.Unlike
field-encryption(kept[EXPERIMENTAL]— it has a stable schema shape on
a real roadmap), correctvisibilityenforcement is undesigned: it needs
owner/org anchors that do not exist today.agent.tenantIdwas already removed
(#2377), agents carry no owner field, and theEXTERNALposture rung is defined
but never derived — soorganizationvsglobalis runtime-indistinguishable.
The semantics, not just the plumbing, are unresolved, so the field is dropped
rather than carried marked.AgentSchemais not.strict(), so existing metadata that still sets
visibilityparses cleanly — the unknown key is stripped, not rejected.- Use
access/permissionsto restrict who can use an agent — both enforced
at the chat route (#1884). - Re-introduce
visibilitywhen the agent listing surface gains real owner/org
semantics; tracked in #1901.
Also updated: authoring form (
agent.form.ts), liveness ledger
(liveness/agent.json), the ADR-0056 D10 authz-conformance matrix (moved from
experimentaltoremoved), and the generated schema reference docs. -
06ff734: feat(spec)!: remove deprecated
aiStudio/aiSeatcapability aliases (#3308)BREAKING (shipped as minor per the launch-window convention). The one-cycle
deprecation window from #3265 is over: the legacy camelCaserequiresspellings
aiStudio/aiSeatare no longer canonicalized toai-studio/ai-seat— they
are now plain unknown tokens, rejected bydefineStacklike any other typo.- Removed exports
DEPRECATED_PLATFORM_CAPABILITY_ALIASESand
canonicalizePlatformCapabilityfrom@objectstack/spec;isKnownPlatformCapability
no longer canonicalizes. defineStackno longer rewrites aliases (thecanonicalizeStackRequirespass
is gone); the serve resolver no longer canonicalizes raw-artifactrequires.
Migration: use the canonical kebab-case tokens
ai-studio/ai-seat. All
first-party configs were migrated in #862/#863; only stacks still carrying the
legacy spelling are affected. Cloud'sobjectos-runtime(pinned to an older
framework) follows on its next.framework-shabump. - Removed exports
-
b659111: feat(spec)!: remove dead author-facing metadata properties (#2377, ADR-0049 enforce-or-remove)
Breaking spec-surface removal, versioned as
minorper the launch-window changeset
policy (amajorwould promote the whole fixed-group monorepo; breaking cleanups ride
the minor line, as with #2402 → 11.1.0).Removes a batch of spec properties that parsed but had no runtime consumer —
authoring them was a false affordance (especially dangerous for AI-authored
metadata). Verified dead against the liveness ledger (packages/spec/liveness/*.json)
and a repo-wide grep of readers. This is the follow-up slice to #2402.Removed (each was
dead+ no reader anywhere)- field (
field.zod.ts):vectorConfig(+VectorConfigSchema+ types),
fileAttachmentConfig(+FileAttachmentConfigSchema+ types),dependencies.
Vector fields keep the live flatdimensionsprop; file/image fields keep the
live flatmultiple/accept/maxSizesiblings. - object (
object.zod.ts):versioning(+VersioningConfigSchema),
softDelete(+SoftDeleteConfigSchema),search(+SearchConfigSchema),
recordName,keyPrefix. Each is now a rejecting tombstone in
UNKNOWN_KEY_GUIDANCEcarrying the upgrade prescription. - action (
action.zod.ts):timeout(server usesbody.timeoutMs; no
action-level timeout is enforced). - agent (
agent.zod.ts):planning.strategy,planning.allowReplan
(onlyplanning.maxIterationsis read by the runtime). - dataset (
dataset.zod.ts):measures.certified(declared-but-unenforced
governance flag — never compiled into the Cube).
Liveness ledgers, the ledger README table, and
api-surface.jsonare updated;
the removed sub-schema keys are dropped fromjson-schema.manifest.json.Migration
- field/agent/dataset/action props: authoring them is now silently stripped
(they never did anything). Remove them. Vector → set flatdimensions;
file/image → set flatmultiple/accept/maxSize. - object props:
ObjectSchema.create()now throws a located error naming the
replacement —versioning/softDelete→ hard deletes +Field.trackHistory/
lifecycle;search→searchableFields;recordName→ anautonumber
Fielddesignated asnameField;keyPrefix→ remove (never had an effect).
Deliberately NOT removed (dead, but entangled — a scoped follow-up)
field.index/columnName/referenceFiltersand object
tags/active/isSystem/abstract/enable.searchable/enable.trash/enable.mru
andagent.tenantIdare surfaced in the Studio metadata-authoring forms
(*.form.ts) — removing them cascades into i18n bundle regeneration, so they are
deferred.action.type:'form'has a dedicated build-time lint (lint-view-refs.ts)
and a first-party showcase usage, so it needs a UX decision.field.columnName
additionally has an ADR-0062 D7 lint. These staydead+authorWarnin the
ledgers. - field (
-
5754a23: feat(spec)!: remove form-surfaced dead metadata props + correct 3 misclassified-live entries (#2377, ADR-0049)
The next enforce-or-remove slice of #2377. Versioned
minorper the launch-window
policy (the fixed group makes amajorpromote the whole monorepo).Removed (dead, no runtime reader — verified in both framework and objectui)
- field:
columnName,index,referenceFilters. This empties the field
dead-prop set.columnNamealso removed its now-moot ADR-0062 D7 lint
(validate-expressions.ts), the deadStorageNameMapping.resolveColumnName/
buildColumnMap/buildReverseColumnMaphelpers, and closes ADR-0062 R10 —
external physical-column mapping isexternal.columnMaponly. - object:
tags,active,abstract— now rejecting tombstones in
UNKNOWN_KEY_GUIDANCE. - agent:
tenantId.
The removed props are dropped from the authoring forms (
field/object/agent.form.ts)
and the regenerated metadata-forms i18n bundles.Corrected to
live(the ledger was wrong — readers existed)- object
isSystem—plugin-sharingeffectiveSharingModeldefaults a
no-sharingModelisSystemobject to public; also read by the security-posture
lint. KEPT. - object
enable.searchable—metadata-protocolglobal search (searchAll)
usesenable.searchable === falseas an opt-out. KEPT. - action
type:'form'— objectuiActionRunner.executeFormroutes it to the
FormView at/forms/:target; a build-time lint validates the target. KEPT.
Deliberately deferred
object.enable.trash/enable.mru— dead, but inertdefault(true)flags set by
~35sys-*.object.tsfiles; removing them is high-churn / low-value. Leftdead
(authorWarn-skipped).Migration
- field/agent props: authoring them was already a no-op; they now strip silently.
columnName→ the physical column is always the field key (rename the field, or
useexternal.columnMapfor external objects);index→ declare it in object
indexes[];referenceFilters→lookupFilters. - object
tags/active/abstract:ObjectSchema.create()now throws a located
error naming the removal. None gated anything at runtime — remove them.
- field:
-
668dd17: Breaking (npm type surface): retire the vestigial feed contracts + protocol surface (ADR-0052 §5 follow-up, #1959).
The
service-feedruntime was deleted in #1955;sys_comment/sys_activity
are the canonical record-collaboration/timeline backend. This removes the dead
type surface that still pointed at the deleted runtime — every removed method was
already unreachable (the feed REST route was never mounted → 404; the protocol
implementation was never wired with a feed service, sorequireFeedService()
could only throw). No behavior changes.No authorable metadata key is removed (the
feeds:object capability flag and
theRecordActivityUI component config are unchanged), soPROTOCOL_MAJOR
stays 15 and this ships asminorrather than a protocol major.FROM → TO migration for every removed export:
@objectstack/spec/contracts—IFeedService,CreateFeedItemInput,
UpdateFeedItemInput,ListFeedOptions,FeedListResult→ removed, no
replacement. Comments/activity are plain records: writesys_comment/ read
sys_activityvia the data engine or the REST data API.@objectstack/spec/api—FeedApiContracts,FeedApiErrorCode,
FeedProtocol, and all feed request/response schemas + types (GetFeed*,
CreateFeedItem*,UpdateFeedItem*,DeleteFeedItem*,AddReaction*,
RemoveReaction*,PinFeedItem*,UnpinFeedItem*,StarFeedItem*,
UnstarFeedItem*,SearchFeed*,GetChangelog*,ChangelogEntry,
SubscribeRequest/Response,FeedUnsubscribeRequest,UnsubscribeResponse,
FeedPathParams,FeedItemPathParams,FeedListFilterType) → removed. Use
the data API againstsys_comment/sys_activity(/api/v1/data/sys_comment/…);
reactions and threaded replies are fields onsys_comment.@objectstack/spec/data—FeedItemSchema/FeedItem,FeedActorSchema/FeedActor,
MentionSchema/Mention,ReactionSchema/Reaction,
FieldChangeEntrySchema/FieldChangeEntry,FeedVisibility,
RecordSubscriptionSchema/RecordSubscription,SubscriptionEventType, and the
data-namespaceNotificationChannel→ removed.FeedItemTypeand
FeedFilterModeare kept (live UI activity-timeline config). For notification
channels useNotificationChannelSchemafrom@objectstack/spec/system.@objectstack/client—client.feed.*(list/create/update/delete/
addReaction/removeReaction/pin/unpin/star/unstar/search/
getChangelog/subscribe/unsubscribe) and the re-exported feed response
types → removed. One-line fix: useclient.data.*onsys_comment/
sys_activity, e.g.client.data.create('sys_comment', { object, record_id, body })
andclient.data.find('sys_activity', { filters: [['record_id', '=', id]] }).@objectstack/metadata-protocol—ObjectStackProtocolImplementationno longer
implements the 14 feed methods; its constructor
(engine, getServicesRegistry?, getFeedService?, environmentId?)becomes
(engine, getServicesRegistry?, environmentId?). One-line fix: delete the third
argument.
-
8abf133: Breaking (discovery response shape): retire the residual feed capability surface (#3180, follow-up to #1959 / ADR-0052 §5).
The feed backend was retired long ago; #1959 removed the feed contracts + SDK. This
removes the last discovery/dispatcher references to it, and fixes a real bug where the
commentscapability was permanentlyfalse.@objectstack/spec—WellKnownCapabilitiesSchema.feedandApiRoutesSchema.feed
(routes.feed) are removed, and the/api/v1/feedentry is dropped from
DEFAULT_DISPATCHER_ROUTES. FROM → TO: clients readingdiscovery.capabilities.feed
ordiscovery.routes.feed→ usediscovery.capabilities.comments; comments/activity
are served by the generic data API onsys_comment/sys_activity
(/api/v1/data/sys_comment/…).@objectstack/metadata-protocol—getDiscovery()no longer emits the always-false
feedservice/capability. Bug fix: thecommentscapability previously keyed off
the deleted'feed'service (so it was permanentlyfalseafter #1955); it now tracks
the presence of thesys_commentobject (provided by the always-on audit slate), so
declared === enforced.@objectstack/client— the internalfeed: '/api/v1/feed'route constant is removed
(it only existed to satisfy the now-removedApiRoutes.feedtype; no client code used it).
-
04ecd4e: feat(validation):
state_machine.initialStatesenforces the FSM entry point on INSERT (#3165)A
state_machinerule'stransitionsonly governs UPDATE — on INSERT the rule
was a no-op, and aselectfield permits ANY declared option as the initial
value. So a record could be born mid-flow (created alreadyapproved), skipping
the whole state machine. This was the gap #3043's mitigation idea assumed didn't
exist (declared ≠ enforced, ADR-0049).state_machinerules gain an optionalinitialStates: string[]— the states a
record may be CREATED in. When set, an insert whose (defaulted) state-field value
is outside the list is rejected server-side withcode: 'invalid_initial_state'.
Omit it to keep the legacy behavior (no initial-state check on insert). A missing
/ empty value is left to required-validation;transitions(UPDATE) is
unaffected. Enforced at the sameevaluateValidationRules(..., 'insert')seam the
engine already runs after field defaults. -
4d5a892: feat(objectql): roll-up
summaryfields can filter which child rows they aggregate (#1868)summaryOperationsgains an optionalfilter— a querywhereFilterCondition
evaluated against each child row, so a summary aggregates only the matching
children instead of the whole collection. This is what lets a single child object
feed several distinct parent totals, which the cross-object rollup templates need:// One `engagement` child → distinct filtered totals. total_signups: { type: 'summary', summaryOperations: { object: 'engagement', field: 'id', function: 'count', filter: { type: 'signup' } }, } // Sum only received receipt lines (3-way match). received_amount: { type: 'summary', summaryOperations: { object: 'procurement_receipt', field: 'amount', function: 'sum', filter: { status: 'received' } }, }
The engine ANDs the predicate with the parent-FK match when it recomputes, and
because the whole filtered aggregate is re-run on every child write, a child that
moves in or out of the predicate (e.g. a status change) keeps the parent current
with no extra wiring. Operator and compound forms work too
(filter: { type: { $in: ['signup', 'trial'] }, amount: { $gte: 100 } }).Purely additive: omitting
filteraggregates every child exactly as before. -
16cebeb: fix(spec): drop the dead
systemFields.ownerkey (#3175 follow-up)ObjectSchema.systemFieldsexposed anowner?: booleanopt-out key that nothing
read — the registry (applySystemFields) only consumessystemFields.tenantand
systemFields.audit, andowner_idprovisioning is governed by the object-level
ownershipproperty ('user' | 'org' | 'none', made first-class in #3185). The
key was declared but wired to nothing.Removed it so the schema only advertises the two opt-outs it actually honors
(tenant,audit). Backward-compatible at runtime: the key was ignored before and
is stripped now (both no-ops). A TypeScript author who setsystemFields.owner
will now see an excess-property error — the fix is to delete the key (it never did
anything) or useownership: 'org' | 'none'to skipowner_id. Also corrected the
staleobjectql/securitydoc that calledaudit"reserved" (it is active). -
86d30af: fix(tenancy): platform-global (
tenancy.enabled:false) objects are never driver-org-scoped (#3249)An org-context read of a platform-global object (e.g.
sys_license, ADR-0066)
could return 0 rows for an authenticated caller while an anonymous read saw the
data: the engine stampedexecCtx.tenantIdinto driver options unconditionally,
and the SQL driver's tenant-field cache could be re-corrupted to
organization_idby a partial re-registration (lifecycle archivesyncSchema,
schema-drift re-sync) whose schema omitted thetenancyblock.- New
isTenancyDisabled(schema)export from@objectstack/spec/data— the
single source of truth for the ADR-0066 platform-global posture, now shared by
the registry (tenant-column injection), the ObjectQL engine, and the SQL
driver. ObjectQL.buildDriverOptionsno longer stampstenantIdfor objects whose
registered schema declarestenancy.enabled: false(an explicitly-passed
optionstenantIdstill wins — deliberate caller intent).SqlDriver(andSqliteWasmDriver) now keep a sticky record of an explicit
tenancy.enabled:falsedeclaration: a later registration without atenancy
block preserves the opt-out instead of re-scoping via the implicit
organization_idheuristic; a registration that carries atenancy
declaration stays authoritative.
- New
-
a2795f6: feat(triggers): declarative time-relative trigger — daily sweep instead of fragile date-equality (#1874)
Time-relative business rules ("alert 60 days before a contract's
end_date")
could only be expressed as arecord_changeflow gated on a date-equality
condition likeend_date == daysFromNow(60). That predicate is only evaluated
when the record happens to change, so it fires only if a record is edited on
exactly the threshold day — i.e. almost never, unattended. The robust
alternative was a hand-written cron + range query that every author
re-implemented (contractsrenewal_alert, hrdocument_expiring_soon,
procurementpo_overdue, …).A flow's start node can now declare a
timeRelativedescriptor instead:config: { timeRelative: { object: 'contracts', dateField: 'end_date', offsetDays: [60, 30, 7], // T-minus reminders — fires on each threshold day // — or — withinDays: 30 // "expiring soon" range; negative = overdue lookback filter: { status: 'active' }, // optional, ANDed with the date window }, schedule: { type: 'cron', expression: '0 8 * * *' }, // optional; defaults to daily 08:00 UTC }
The new
time_relativetrigger (shipped in@objectstack/trigger-scheduleas
TimeRelativeTriggerPlugin) sweeps the object on that schedule and launches the
flow once per matching record, with the record on the automation context —
so the start-nodeconditiongate and{record.<field>}interpolation work
exactly as for a record-change flow. Because the window is evaluated every day,
a threshold is never missed regardless of when the record last changed. The
discovery query runs as a system operation (RLS-bypassing) and is capped
(maxRecords, default 1000) so a mis-scoped window can't fan out unboundedly;
per-record failures are isolated so one bad row never aborts the sweep.The automation engine routes a start node carrying
config.timeRelativeto the
time_relativetrigger (ahead of the plainscheduletrigger, whose behavior is
unchanged), andos validategains readiness checks for the new descriptor
(unknown swept object, ambiguous draft status). New authorable spec key:
TimeRelativeTriggerSchema(@objectstack/spec/automation).
Patch Changes
-
22013aa: Split the overloaded
managedBy: 'system'bucket into engine-owned vs. admin-writable, and enforce engine-owned writes (ADR-0103, #3220). Thesystembucket conflated two incompatible write policies: rows a platform service owns end to end (never user-written), and platform-defined schema whose rows are legitimately admin/user-writable. It carried the same all-false affordance row asbetter-auth/append-onlybut, unlikebetter-auth, had no engine enforcement — a wildcard admin could raw-write these rows through the generic data API (ADR-0049 gap).Rather than add a new
managedByenum value (which would fall through to fully-editableplatformdefaults on already-deployed Console clients), the write policy is now the resolved affordance (resolveCrudAffordances= bucket default +userActions), and engine-owned is defined as asystem/append-onlyobject that grants no write:- Writable set declares
userActions— the RBAC link tables (sys_user_position,sys_user_permission_set,sys_position_permission_set),sys_user_preference,sys_approval_delegation, and the messaging config grids (sys_notification_preference/…_subscription/…_template) now declareuserActions: { create, edit, delete: true }. The affordance is a declaration only — theDelegatedAdminGate/ RLS / permission sets remain the authz. - Engine-owned objects locked to reads —
apiMethods: ['get','list']added where absent (jobs, notifications, approval request/approver/token/action,sys_record_share,sys_automation_run, mail/settings/secret audit, the messaging delivery pipeline).sys_secretis explicitly read-locked (an emptyapiMethodsarray fails open). sys_import_jobstays engine-owned: the REST import route now writes its job rowsisSystem-elevated (attribution preserved via the explicitcreated_bystamp) and the object is locked to['get','list'].- New engine write guard (
assertEngineOwnedWriteAllowed, plugin-security) fail-closed rejects user-context generic writes to engine-ownedsystem/append-onlyobjects, keyed off the resolved affordance;isSystemand context-less engine/service writes bypass by construction. Wired into the security middleware alongside the other data-layer gates. reconcileManagedApiMethods(objectql registry) now runs for every managed bucket, not justbetter-auth: any advertised write verb an object's resolved affordances forbid is stripped at registration with a warning (the drift backstop, ADR-0049)./me/permissionsclamp (plugin-hono-server) now clampssystem/append-onlyas well asbetter-auth, so the client hint reflectspermission ∩ guard.
Potentially breaking: a downstream/third-party
systemobject that advertised generic write verbs relying on today's fail-open behaviour will have those verbs stripped (with a warning) and user-context generic writes to it rejected. DeclareuserActionsopening the verbs the object legitimately takes from a user context.better-authkeeps plugin-auth's identity write guard unchanged; the row-levelmanaged_byprovenance vocabulary (ADR-0066) is a different axis and is untouched. - Writable set declares
-
3ad3dd5: Annotate the schema-only event/subscription/connector surfaces flagged by the #3197 audit with explicit "not yet enforced / not yet implemented" notes in their doc comments and
.describe()texts, so authoring metadata against them is no longer silently swallowed. No runtime behavior or schema shape changes — documentation only.Surfaces annotated (each trace re-confirmed against the current tree before annotating):
GraphQLSubscriptionConfigSchema(api/graphql.zod.ts) — no subscription transport exists; the GraphQL HTTP entry serves query/mutation only.WebSocketMessageType+ module header (api/websocket.zod.ts) — no WebSocket server is mounted (#2462); the protocol is a future wire contract.RealtimeEventType(api/realtime.zod.ts) — zero runtime importers; the engine emitsdata.record.*names (which don't match this enum's members) and nothing emitsfield.changed.- Connector
webhooks/WebhookConfigSchema/WebhookEventSchemaandtriggers/ConnectorTriggerSchema(integration/connector.zod.ts) —AutomationEngine.registerConnectorreads onlyactions; webhook events and trigger definitions parse but are never dispatched or polled. - Automation
ConnectorTriggerSchema/TriggerRegistrySchema(automation/trigger-registry.zod.ts) — no runtime importer; thestreamtrigger mechanism exists only here. NotificationChannelSchema(system/notification.zod.ts) + the mirroredNotificationChannelcontract type — implemented delivery channels areinbox/email/sms;push/slack/teams/webhookdead-letter, and the enum'sin-appdoes not match the registeredinboxchannel id.
The audit's sixth row (
SubscriptionEventType, formerlydata/subscription.zod.ts) needed no annotation — it was already removed outright by the feed-contract retirement (#1959). -
a8aa34c: Enforce validation rules,
requiredWhen, and per-optionvisibleWhenon multi-row updates (#3106). The bulk branch ofengine.update(options.multi→driver.updateMany) previously never calledevaluateValidationRules, so every object-level rule (script,state_machine,format,cross_field,json_schema,conditional), field-levelrequiredWhen, and per-optionvisibleWhencheck was a silent no-op there. The engine now reads the row-scoped match set (the same AST the write binds, one query shared with thereadonlyWhenbulk strip) and evaluates the payload against each matched row's prior state; any error-severity violation rejects the whole batch withValidationError(annotated with the failing record id) before anything is written. Schemas needing no prior state (format/json_schema-only) are evaluated once against the payload with no fetch, and rule-free schemas are unaffected. Behavior change: bulk writes that previously slipped past declared rules now throw. Doc comments inrule-validator.tsandvalidation.zod.tsno longer overstate coverage and name the remainingevents: ['delete']gap (tracked separately). -
a3823b2: Collapse the hook event taxonomy from 18 declared events to the 8 the engine actually dispatches (#3195). The removed 10 (
beforeFindOne/afterFindOne,beforeCount/afterCount,beforeAggregate/afterAggregate,beforeUpdateMany/afterUpdateMany,beforeDeleteMany/afterDeleteMany) were declared inHookEventbut never fired — the enum mirrored the engine method table instead of domain events, so a hook subscribing to them registered fine and then silently no-op'd.findOnenow fires the samebeforeFind/afterFindhooks asfind— the read event attaches to record materialization, not the engine method, so one subscription covers every read shape (no separatebeforeFindOne/afterFindOne).- Bulk (
multi: true) updates/deletes already fire the singularbeforeUpdate/beforeDelete/afterUpdate/afterDeleteevents with the row-scoping predicate inctx.input.ast; this is now documented, and there is no*Manyevent. - Read authorization / row filtering is the RLS/permission-rule layer's job and field masking is field-level metadata — neither is a hook every author must re-attach.
engine.registerHooknow warns when a hook subscribes to an event the engine never dispatches, so enum-vs-dispatch drift can't recur silently.
No shipped hook or authored metadata used any of the removed events; authoring one now fails loudly at parse/validate time instead of registering a dead hook. Skills and docs updated to teach the 8 events and the declarative alternatives.
-
5e3301d: Document two validation-rule facts surfaced by the 2026-06 liveness audit (follow-up to #3106 / #3184), and clean up a stale form-schema mirror — no runtime behavior change:
label/description/tagson validation rules are governance / editor metadata (surfaced to the Studio rule editor and rule listings), not evaluated on the write path. Documented as such onBaseValidationSchemarather than removed — they are set by nearly every example rule and feed the/meta/typeseditor form, so they are declared on purpose, not silent no-ops.cross_fieldevaluates identically toscript(same CEL predicate path); onlyfields[0]is read, to target the violation at a field. Documented the overlap on the schema, itsfields.describe(), and the validation docs so authors can choose between them; the variant is kept for the field-targeting affordance and backward compatibility.- Removed dead form-field entries (
scope,caseSensitive,url,handler) and the staletype=uniquehint from the hand-writtenHAND_CRAFTED_SCHEMAS['validation']fallback in@objectstack/metadata-protocol— leftovers from the removedunique/async/customvariants. - Added the missing
beforeDeletelifecycle-hook pointer to the validation docs' "not a rule type" callout, so delete-time guards aren't stranded now that validation has nodeleteevent (#3184).
-
46e876c: fix(spec): declare
summaryOperationssub-fields in the Field metadata form (#3257)fieldForm(the registered metadata form for editing a Field) previously
declaredsummaryOperationsas a barecompositewith no sub-fields, so a
protocol-driven renderer had to fall back to a raw JSON editor. It now declares
the inner shape explicitly —object(ref:object),function(select),
field,relationshipField, andfilter(bound towidget: 'filter-condition')
— mirroring thesummaryOperationsZod schema and surfacing the roll-upfilter
added in #1868. Also gates the block todata.type == 'summary'.Small step toward #3257 (making the Studio field designer metadata-driven rather
than hand-coded); the live objectui inspector already edits these fields. -
158aa14: feat(automation): mark the loop
collectionconfig field as an interpolate() template so designer forms render it correctly (#3304)The flow designer generates a node's config form from its published
configSchema(ADR-0018). A string property can now carry anxExpression: 'expression' | 'template'marker — riding the same Zod.meta()→ JSON-Schema
channel asxRef/xEnumDeprecated— that declares whether the string is bare
CEL or aninterpolate()single-brace{var}template.The
loopnode'scollection(e.g.{tasks}) is a template, so it is now
markedxExpression: 'template'on both the canonicalLoopConfigSchemaand the
shipped descriptor'sconfigSchemaliteral (service-automation loop-node).
Without the marker the designer renderedcollectionas plain text online while
the offline hardcoded form rendered it as a mono expression editor, and the CEL
brace-trap false-flagged{tasks}as a malformed condition. The marker closes
that divergence — objectui #2670 Phase 3 (#2699) already consumes it.Additive and backward-compatible: an unknown
xExpressionvalue is ignored by
the designer, and runtime behavior is unchanged. Filling the same marker in on
the remaining node types (map/decision/script and the node types that publish no
configSchemayet) is tracked as follow-up in #3304. -
d2723e2:
MetadataManager.register()/unregister()now announce tosubscribe()watchers. Both updated the registry, persisted to writable loaders and published to realtime, but never fired the watch callbacks — sosubscribe()looked like it covered every write while silently missing all of them. Only thesaveMetaItempath (via the repository watch stream) and the filesystem watcher ever reached a subscriber. Runtime consumers that cache metadata — notably ObjectQL's SchemaRegistry bridge, the component that decides what is queryable — went stale on every other write until the process restarted.Announcing is now the default, so a new call site is correct without knowing this contract exists. This is a contract fix rather than a bug fix: the one live behavior change is that runtime datasource writes (
datasource-admin) now reach the HMR SSE stream, which subscribes to every registered type.unregisterPackage()/bulkUnregister()also announce their deletes now — correct, but latent, since neither has a production caller today.Bulk ingest opts out explicitly with the new
MetadataWriteOptions({ notify: false }) — boot-time filesystem priming, artifact ingest, and ObjectQL's registry bridge, each of which either runs before consumers cache anything or announces the whole batch once (as the artifact reload path does viametadata:reloaded). The bridge in particular MUST stay silent: it copies objects out of the SchemaRegistry, and announcing would feed them back through a handler that re-registers under_packageId ?? 'metadata-service', overwriting the true package provenance of every object whose body carries no_packageId.Additive only —
register(type, name, data)andunregister(type, name)keep working unchanged.Fixes #3112.
-
beaf2de: fix(metadata-protocol): strip static
readonlyon INSERT at the data-write ingress (#3043)#2948/#3003 made static
readonly: truefields server-enforced on UPDATE (a
non-system PATCH forgingapproval_status: 'approved'is silently stripped in
the engine), but INSERT was exempt. For approval/status/verdict columns that
exemption was the shorter attack: instead of the #3003 draft-then-PATCH move, a
non-system caller couldPOSTa record alreadyapproval_status: 'approved'in
one step — and the UPDATE-only strip never reached it.The strip now also runs on INSERT, but at the external data-write ingress
(DataProtocol.createData/createManyData/batchData/cloneData) rather
than in the engine. That seam is the single point every external programmatic
create funnels through — the REST CRUD route, the GraphQL/MCP dispatcher
(bridge.create→callData→createData), and bulk import — while trusted
internal writers (better-auth's adapter, the metadata repository, the seed
loader) callengine.insertdirectly and bypass it. Enforcing at the ingress
protects every caller/agent path at once without stripping the internal writers
that legitimately seed read-only columns on create (identity provisioning,
provenance stamps, event-log cursors) — the blast radius an engine-level insert
strip would have.- Caller-forged only, at the ingress. The payload here is raw caller input
(the security middleware stampsowner_id/organization_idlater, inside
engine.insert), so only keys the caller actually sent are dropped; server
stamps are added afterwards and are unaffected. - Re-derives the default. A stripped field falls back to its declared
defaultValuein the engine (a forgedapproval_statusbecomesdraft, not
NULL). - System-context exempt.
isSystemwrites still seed read-only columns. - Silent (HTTP 2xx), per-row on batch/import.
readonlyWhenstays
INSERT-exempt (a conditional lock needs a prior record). - Author-defined business objects only. Platform objects (
managedByset,
or thesys_namespace) carry their own field-write governance that a silent
strip must not pre-empt — e.g. ADR-0086 REJECTS (403) a forged
managed_by:'package'onsys_permission_set, and #3004 rejects a forged
owner_id; several of those columns arereadonly, so stripping them here
would swallow the payload the guard is meant to reject. The #3043 threat is app
approval/status fields, neversys_— the same boundaryapplySystemFields
uses for ownership.
Behavior change: a non-system create through the data API (REST / GraphQL / MCP /
import) can no longer seed areadonlycolumn from the payload. Flows that
legitimately write read-only columns at creation must run with a system context
(isSystem), the same requirement the UPDATE strip already imposes. - Caller-forged only, at the ingress. The payload here is raw caller input
-
e0859b1: fix(formula): retire the
jsexpression dialect and fix thehasDialectfalse-positive (#3278)The
jsexpression dialect was declared inExpressionDialectbut never
shipped — it existed only as a registry stub with no engine and no author helper
(cel/F/P→ CEL,tmpl→ template,cron→ cron; nothing ever emitted
js). Per ADR-0049 (enforce-or-remove) it is removed from the enum; the set is
now{cel, cron, template}.Procedural JavaScript is unaffected: it remains the L2 authoring surface —
the sandboxed, capability-gatedScriptBody { language: 'js' }in hook/action
bodies — which is a separate enum (hook-body.zod.ts), not an expression
dialect.Also fixes a latent bug in
hasDialect: it detected stubs via
dialect.startsWith('stub:'), but stubs were registered under their real name,
so the check was dead code andhasDialect('js')returned a false-positive
true. With the stub removed,hasDialectreports only registered real
engines, and the registry test now asserts the negative case (hasDialect('js') === false) so the gate can actually go red.No runtime behavior changes for any valid persisted artifact — no producer ever
emitteddialect: 'js'. See the ADR-0058 addendum. -
8923843: Reject view containers that define no views. A flat list-view object (
{ name, label, type, columns, ... }) parses to an emptyViewSchemacontainer because Zod strips unknown keys — zero views register and the Console silently renders nothing.defineView()now throws on a zero-view container, andos validategains aview-container-shapecheck (validateViewContainersin@objectstack/lint) that reports flat or emptyviews: []entries pre-parse with a wrap-it fix hint. -
f16b492: Remove the dead
'delete'member from the validation-ruleeventsenum (#3184). The rule evaluator only runs on the insert/update write path —engine.deletenever invokes it — so a rule declaringevents: ['delete']was a silent no-op (flagged in #3106 anddocs/audits/2026-06-validationschema-property-liveness.md). The enum now admits onlyinsert/update; guard deletions with abeforeDeletelifecycle hook instead. No shipped metadata declaresevents: ['delete']; any off-spec metadata that did now fails loudly atos validate/ registration rather than parsing and doing nothing. Also narrows the two hand-written mirrors (rule-validator.tsBaseRule,metadata-protocolJSON-schema form helper — whose staletypeenum listing removedunique/async/customvariants is corrected in the same pass), updates the doc comments, the published data skill, and the hand-written validation doc. -
4b6fde8: Trim the dead
undeleteandapiwebhook triggers (#3196).WebhookTriggerTypedeclared five triggers but only three ever fired:undeletehad no event source — the engine has no soft-delete/restore capability (deleteis a hard delete; nodeleted_atconvention, no restore operation, anddata.record.undeletedis never emitted). Theundeletedcase in the auto-enqueuer's action mapper was dead code awaiting a producer that doesn't exist.api("manually triggered") had no fire path — the only webhook HTTP surface re-queues already-failed deliveries; nothing originates a manual fire.
Both are removed from the enum (contract-first, matching #3184/#3195): authoring a webhook on a removed trigger now fails loudly at
os validate/ registration instead of registering a webhook that silently never fires. No shipped webhook metadata used either. The auto-enqueuer now also warns when a persistedsys_webhookrow carries a trigger it can't map to an emitted record event (a drift-guard, so a dead trigger can't silently no-op again). Reintroduceundeleteonly alongside a real restore subsystem, andapionly alongside a real manual-fire endpoint. Updated thesys_webhooktrigger options, field help (all locales), docs, and reference; added rejection tests. -
2018df9: Unify the developer-facing org identifier in JS hooks —
organizationIdis now the blessed name;session.tenantIdbecomes a deprecated alias (#3280). The caller's active organization was surfaced to hook authors asctx.session.tenantId, while everything else on the developer surface — theorganization_idcolumn,current_user.organizationIdin RLS/sharing, and seed rows — already saidorganization. A hook author had to internalize the hidden equationtenantId === organizationIdto move between surfaces. This is additive and non-breaking:ctx.session.organizationIdis added as the blessed name;ctx.session.tenantIdstill carries the identical value but is marked@deprecatedin its TSDoc. Both come from the same resolvedExecutionContext.tenantId(which the kernel derives fromsession.activeOrganizationId).ctx.user.organizationIdis added to the ergonomicusershortcut, so a hook that needs "the current org to filter by" writesctx.user.organizationIdwith zero relearning — matchingcurrent_user.organizationId(RLS) and theorganization_idcolumn. The engine now populatesctx.user({ id, email?, organizationId? }) at every hook event that already carries asession; it staysundefinedfor system / unauthenticated writes.
No behavior change and no breaking rename. The generic driver-layer tenancy abstraction (
ExecutionContext.tenantId,DriverOptions.tenantId,SqlDriver.applyTenantScope,TenancyConfig.tenantField) is deliberately untouched — that layer's isolation column is configurable and legitimately carries an environment id in per-environment (database-per-tenant) kernels. Hook-authoring docs now teachorganizationIdand distinguish the two isolation axes: org row-scoping (organization_id, shared DB) vs environment / database-per-tenant (service-tenant,driver-turso). Community edition never populates an org, soorganizationIdisundefinedthere. -
fc5a3a2: The
viewmetadata type-schema now validates all three runtimeviewshapes instead of stripping two of them to{}.metadata-type-schemas.tsmappedviewto the aggregate containerViewSchema({ list, form, listViews, formViews }, every slot optional). Zod strips unknown keys, so the two non-container shapes aviewbody actually carries at runtime — a standalone ViewItem record ({ name, object, viewKind, config }) and a console personalization overlay (raw view config + identity inherited bynormalizeViewMetadata, #2555) — both strip-parsed to{}. That made the422check insaveMetaItemand read-timecomputeMetadataDiagnosticsa no-op for those shapes: a brokenconfig(e.g. a kanban missinggroupByField) saved with a false200and badged valid, and the view create-seed test validated against nothing.viewnow maps to a newViewMetadataSchema— a union over the three shapes, each validated genuinely:- defineView container — non-empty (
ViewSchemarefined to require at least one oflist/form/listViews/formViews; an empty container is rejected, mirroringdefineView). - ViewItem record —
ViewItemSchema; the nestedconfigis validated against ListView/FormView. - Flattened personalization overlay — inline ListView/FormView config plus optional identity fields. Structural guards pin
config/list/form/listViews/formViewstoundefinedso a malformed record or container can never be rescued through this lenient branch with its real payload silently stripped.
All members strip-parse (no
.strict()), so auxiliary Studio round-trip keys (isPinned,sortOrder, …) still ride along without a false422, andsaveMetaItemkeeps persisting the body verbatim.z.toJSONSchema()emits the schema as ananyOfof the four members, which/api/v1/meta/types/viewserves to Studio's SchemaForm.Fixes #3095.
- defineView container — non-empty (
-
8ff9210: fix(spec): enforce the
ViewFilterRuleoperator enum with legacy-alias
normalization (#3373)ViewFilterRule.operatorwas previously an open string, so views could persist
operators the runtime cannot evaluate. The Zod schema now constrains it to the
supported operator enum and normalizes the known legacy aliases to their
canonical form on parse. This is a public spec/api-surface change
(packages/spec/api-surface.json) that landed onmainin #3373 without a
changeset; this backfills it so the fix ships in the next release instead of
being silently stranded.