-
b496498: feat(spec): add
responsiveStylesto the UI page-component envelope (ADR-0065)ResponsiveStylesSchema/StyleMapSchemamodel the SDUI scoped-styling primitive — per-breakpoint CSS-property maps (large/medium/small/xsmall) compiled to id-scoped CSS at render.PageComponentSchemagains an optionalresponsiveStylesfield: the preferred, build-independent, collision-free styling channel for metadata-authored pages (distinct from the layout-orientedresponsiveconfig). Prefer design-token values.
-
49da36e: feat(analytics): correct analytics over federated objects (ADR-0062 Phase 3, D6)
Analytics over an external (federated) object now aggregates against the correct remote table instead of silently querying the wrong one. The
NativeSQLStrategyhand-compilesFROM "<object>"and bare column references, which bypass the driver's physical-table resolution (external.remoteName/remoteSchema/columnMap). It now declines any query whose base or joined object is federated, routing it to theObjectQLStrategy— whoseengine.aggregate()goes through the driver'sgetBuilderand already honoursremoteName/remoteSchema(#2138/#2149). This "reuses the driver's resolution" (D6) rather than re-implementing it.Adds an optional
StrategyContext.isExternalObject(objectName)hook (reported by the analytics plugin from the object'sexternalblock). Purely additive — with no hook, behavior is unchanged for managed objects. -
ac79f16: feat(datasource): auto-connect declared external datasources (ADR-0062 Phase 1, D1/D2/D5)
A declared external datasource is now connected to a live ObjectQL driver and its federated objects are queryable with zero app code — no
onEnabledriver wiring. Implements ADR-0062 Phase 1.- D1 — one connect path. New
DatasourceConnectionServicein@objectstack/service-datasourceowns the single "definition → live driver" path: build via the injected driver factory → resolveexternal.credentialsRefvia theSecretBinder→ connect →engine.registerDriverunder the datasource name → register the datasource def → sync each bound federated object's read metadata (DDL-free). Both origins converge on it: the runtime-adminregisterPoolnow delegates here, andAppPluginauto-connects code-defined datasources. Exposed as the'datasource-connection'kernel service. - D2 — opt-in-safe gate. A declared datasource auto-connects only when it is
external, an object explicitly binds to it viaobject.datasource, or it sets the newautoConnect: trueflag. A managed datasource that nothing explicitly binds (incl. ones referenced only by adatasourceMappingrule, e.g.examples/app-crm's:memory:datasources) stays metadata-only — existing apps are byte-for-byte unchanged. See the ADR-0062 D2 implementation note. - D5 — lifecycle, ordering & policy. Connect happens in
AppPlugin.start()(before thekernel:readyvalidation gate, relying on the kernel's init-all-then-start-all ordering). Fail-fast for a declaredexternaldatasource withvalidation.onMismatch: 'fail'; degrade-with-warning otherwise (and always for runtime-admin/rehydrate, so a UI action or replica blip never bricks the server). Adds a host-injectableDatasourceConnectPolicy(open-core default allows; a multi-tenant host binds a stricter fail-closed policy for egress isolation) consulted before every connect — one connect path, no cloud fork.
Adds
datasource.autoConnectto the spec. The legacyonEnable+ctx.drivers.registerbridge remains supported as an escape hatch (idempotent vs. auto-connect). No behavior change for managed apps. - D1 — one connect path. New
-
d7ff626: spec(action): a
scriptaction must declare an executable binding — reject at author/compile time when it has neither an inlinebodynor atarget.A
type: 'script'action with nobodyand notargetregisters no runtime handler:AppPluginskips it, and invoking it falls through to the wildcard lookup and fails withAction '<name>' on object '*' not found(the #2169 "Mark Done" bug). The shape was schema-valid and passed coverage tests, so the break only surfaced when a user clicked the button.ActionSchemanow enforces the invariant viasuperRefine:scriptrequiresbody || target(mirroring the existing "non-script types requiretarget" rule).body-bound actions are auto-registered by the runtime;target-bound actions name a function wired imperatively (e.g. viaonEnable). This only rejects configurations that were already non-functional at runtime — verified against the full monorepo build (every shipped bundle still compiles). -
e16f2a8: BREAKING: the system object
sys_departmentis renamed tosys_business_unit— object + member table (sys_department_member→sys_business_unit_member), fields, and i18n — with no compatibility alias. Any deployment holdingsys_departmentrows, or metadata that references the object by name (lookups, list views, queries, sharing/approval scopes), must migrate tosys_business_unit. A renamed shipped system object is a breaking change to the platform's public data surface, so this lands as a major. Verified per ADR-0059's pre-publish hotcrm gate: no published downstream consumer references the old name.ADR-0057 — ERP authorization core. Adds permission-grant access DEPTH (
own/own_and_reports/unit/unit_and_below/org), renamessys_department→sys_business_unit(no aliases — see BREAKING above), introduces the platform-ownedsys_user_roleassignment, and seeds stack-declaredroles/sharingRulesintosys_role/sys_sharing_ruleat boot (closes #2077). Hierarchy-relative scopes are delegated to a pluggableIHierarchyScopeResolver(open edition fails closed to owner-only;defineStackerrors withoutrequires: ['hierarchy-security']). Also fixes a latent over-grant whereengine.find({ filter })was ignored (driver readswhere) — normalizedfilter→wherein the engine. -
e411a82: feat(ai): split
ask/buildagents by surface + tool scoping (ADR-0063/0064).Two kernel agents bound by surface, not a per-turn classifier.
SkillSchemagainssurface: 'ask'|'build'|'both'andAgentSchemagainssurface: 'ask'|'build'(ADR-0063 §3); an agent's tools are exactly the union of its surface-compatible skills' tools — incompatible binding is a load error inresolveActiveSkills(ADR-0064 §3). Theaskagent is now data-only (the ADR-0040 unified "INTENT FIRST" classifier and thebuildRegisterActivedegradation shim are removed); a newschema_reader(surface:'both') owns the shared readsdescribe_object/list_objects/query_dataso the build agent reuses them without dual-listing.*.agent.tsis closed to third parties: theagentmetadata-type isallowRuntimeCreate:false, allowOrgOverride:falseand the runtime catalog lists only platform agents (ADR-0063 §2). Renamesdata-chat-agent.ts→ask-agent.ts,DEFAULT_DATA_AGENT_NAME→ASK_AGENT_NAME(thedata_chat/metadata_assistantaliases stay resolvable). -
a581385: Propagate a dataset measure's declared currency to the analytics result field.
Adds an optional
DatasetMeasure.currency(ISO 4217) on the semantic layer and carries it onto each measure result field alongsidelabel/format, so a currency-aware client (Intl symbol) can render¥1,234/$616,000from a real currency code instead of a plain number or a$baked intoformat. Additive and optional — existing datasets are unaffected. -
220ce5b: Resolve the tenant default currency onto ExecutionContext.
Adds
ExecutionContext.currency(ISO 4217) and resolves it from thelocalization.currencysetting alongsidetimezone/locale— in both the runtimeresolveExecutionContextand the REST mirror. This is the foundation for the documented "applied when a currency field omits its own" fallback: the tenant default is now carried on every request context, so analytics enrichment, formatters, and renderers can resolve a measure/field currency down to the org default instead of hard-coding it. Undefined when no tenant default is configured (consumers then render a plain number). -
6ca20b3: ADR-0058 D1 follow-through — RLS predicates are now canonical CEL. Migrated every seeded RLS
using/check(default permission sets, showcase, and theRLS.ownerPolicy/tenantPolicy/allowAllPolicyhelper factories) from the legacy SQL-ish form (=,IN (...)) to pure CEL (==,in), so authors and AI learn ONE expression language. ThesqlPredicateToCelbridge is retained as a DEPRECATED transitional shim: a stored SQL-style predicate still compiles (no silent deny on legacy data) but emits a deprecation warn; canonical CEL passes through as a no-op. No runtime behavior change — CEL and the old SQL form compile to the identical FilterCondition. -
5f875fe: spec: add
defineXfactories for the remaining 16 writable domains and the 6 missingXInputaliases — one consistent, type-safe authoring entry per domain (#2035).New factories:
defineDatasource,defineConnector,definePolicy,defineSharingRule,defineRole,definePermissionSet,defineEmailTemplateDefinition,defineReport,defineWebhook,defineObjectExtension,defineCube,defineMapping,defineTheme,defineTranslationBundle,definePage,defineAction. Each mirrors the 19 existing factories (XSchema.parse(z.input<…>)): input-shape ergonomics + authoring-time validation. Because a factory is a value import, a broken import hard-errors instead of silently degrading toany(the #2023 failure mode), and errors surface at.parse()time with field-level messages.Also adds the previously-missing input aliases
PolicyInput,CubeInput,MappingInput,ThemeInput,TranslationBundleInput,PageInput.Purely additive: no existing exports change.
-
b469950: feat(spec): add a
treeview type to the ListView schema'tree'is now a validListView.type(andVisualizationType), backed by a newTreeConfigSchema(parentField/labelField/fields/defaultExpandedDepth, passthrough). This lets a self-referencing object be served as a tree-grid; without it the runtime Zod-validates view metadata and silently dropstype:'tree'. Renderer ships in objectui@object-ui/plugin-tree.
-
2a1b16b: fix(ADR-0015): honor
external.remoteName/external.remoteSchemaon the federation read path.The query path previously resolved an external object's physical table from the object name, ignoring its
externalbinding — so a federated object bound to a differently-named remote table failed withno such table, and ADR-0015's ownwh_order→mart.fact_ordersexample was unqueryable. The SQL driver now resolves the remote table (remoteName, plusremoteSchemavia.withSchema()on pg/mysql) and registers external objects' read-coercion metadata without DDL (SqlDriver.registerExternalObject, routed from the engine/plugin schema-sync). The managed path is unchanged. See ADR-0015 §18. -
3efe334: Honor a nested
wherefilter insideexpandon lookup/master_detail expansion.The expand post-processor batch-loads related records with an
id $in [...]query but never merged the nested QueryASTwhere, so a documentedexpand: { rel: { where: {...} } }filter was silently ignored and every related record came back. The nested filter is now AND-merged into the batch query via an explicit$andgroup ({ $and: [{ id: { $in } }, nestedAST.where] }) — robust against a nested filter that itself keysidor uses a top-level$or/$and, where a shallow spread would clobber or reorder the constraint.limit/offset/orderByremain intentionally not honored on the expand path: it batch-loads every parent's related records in one$inquery and re-keys them per parent by foreign key, so a per-parent page size or ordering can't be expressed there. Docs and the schemadescribe()are updated to match, with a guard test assertinglimit/offsetare not pushed into the expand query. -
feead7e: fix(spec): make
GanttConfigSchemaforward-compatible via.passthrough().The gantt renderer (objectui plugin-gantt) keeps adding view-config knobs (e.g.
lockField,defaultCollapsedDepth) ahead of this schema. Without passthrough, the console — which validates the view config against a bundled copy of this schema before handing it to the renderer — strips any field not declared here, so every new renderer knob needs a spec release + console rebuild before it can take effect. Adding.passthrough()lets unknown fields flow through to the renderer, decoupling renderer releases from spec releases. Known fields keep their validation; the renderer still only reads what it understands.
-
e7f6539: feat(spec,sharing): canonical OWD vocabulary on
object.sharingModel(ADR-0056 D1)Reconciles the Org-Wide-Default naming so authors use ONE vocabulary.
object.sharingModelnow accepts the canonical OWD names —private|public_read|public_read_write|controlled_by_parent— alongside the legacyread/read_write/fullaliases (kept, non-breaking). The sharing runtime maps them onto the three enforced behaviours (public_read≡ legacyread= everyone reads / owner writes;public_read_write= unscoped). Unknown values remain rejected by the enum (authoring-time, fail-closed). The showcase announcement now declares the canonicalpublic_read, exercised end-to-end by the public-read dogfood proof. -
2365d07: feat(sharing): configurable role-hierarchy widening —
role_and_subordinatesrecipient (ADR-0056 D6)Role-hierarchy access widening ("a manager sees records shared with their team") is now implemented and configurable per sharing rule, not a hardcoded no-op. The
role_and_subordinatesrecipient (declarable onsys_sharing_rule.recipient_type) expands, at evaluation time, to the named role plus every subordinate role by walking thesys_role.parenthierarchy via a newRoleGraphService(mirroring the department/team graphs; cycle-safe). PreviouslyRole.parentwas declared but never consumed — a silent no-op flagged by the ADR-0056 audit. This is the Salesforce "grant access using hierarchies" model expressed declaratively: each rule chooses whether to roll up the hierarchy. Unit-proven (role-graph traversal, subordinate-user expansion, cycle safety); the recipient is added to the authoring select + theSharingRuleRecipientTypecontract. -
6595b53: feat(security): app-declarable default profile (
isDefault, ADR-0056 D7)An app can now declare its default access posture for authenticated users who have no explicit grants, via
isDefault: trueon a permission set — instead of always inheriting the built-inmember_default. The SecurityPlugin resolves the fallback from theisDefaultprofile when no explicitfallbackPermissionSetis configured (falling back tomember_defaultwhen none is declared — non-breaking). This is the foundation for SSO/JIT provisioning (mapping IdP claims → a declared default profile). Proven by theshowcase-default-profiledogfood test: a sign-up governed by a custom default that grants onlyshowcase_announcementcan read it but is deniedshowcase_private_note(which themember_defaultwildcard would have allowed). -
36138c7: feat(autonumber): date, {field} and per-scope counter reset for autonumber formats
autonumberFormatpreviously only understood a single{0000}sequence slot — everything else was a fixed literal prefix on one global counter. Real MES/eHR record numbers need three more token classes, so the format is now tokenized by a shared pure renderer in@objectstack/spec(parseAutonumberFormat/renderAutonumber) that the engine fallback and the SQL driver both call, so they emit byte-identical numbers (#1603 parity):- Date tokens —
{YYYY}{YY}{MM}{DD}{YYYYMMDD}resolve the calendar day in the request's business timezone (ExecutionContext.timezone, ADR-0053; UTC fallback), threaded through the newDriverOptions.timezone. {field}interpolation —{section}{island_zone}{000}substitutes record field values into the prefix.- Per-scope counter reset — the counter's scope is the rendered prefix before
the sequence slot, so
AD{YYYYMMDD}{0000}resets daily,{section}{island_zone}{000}numbers per group, and{plan_no}{000}numbers per parent — all from one mechanism, no separate reset config.
Fixed-prefix formats like
CASE-{0000}render an empty scope and keep their single global counter, so existing sequences are unchanged. The persistent_objectstack_sequencestable is keyed by akey_hash(SHA-256 ofobject, tenant_id, field, scope) — a single 64-char primary key that keys every dialect uniformly, stays within MySQL's utf8mb4 index-length limit (four raw columns would not), and letsscopebe a generous non-indexed column. Deployments with an older table (3-column, or an interimscopecolumn) are migrated in place on first use, carrying existing counters toscope=''.Guardrails:
- Empty interpolated field is a hard error, not a silent mis-number. A
{field}token whose value is missing at create time would render to an empty prefix and collapse the record into the wrong counter scope. Both the SQL driver and the engine fallback now refuse to generate and throw a clear error naming the empty field (sharedmissingFieldValueshelper). - Build-time lint (
@objectstack/cli compile).autonumberformats are checked against the object's fields: a{field}token naming a non-existent field (or the autonumber field itself) fails the build; a token naming an optional field emits an advisory warning to mark itrequired: true. - Migration fails safe. If a legacy table cannot be migrated to the
key_hashshape, fixed-prefix sequences keep working via the legacy key and a per-scope write raises an actionable error instead of corrupting counters. - Long
{field}scopes are supported (e.g. a long{plan_no}): the non-indexedscopecolumn and hashed key remove the old varchar/PK length ceiling.
Notes on inherent semantics (documented, not bugs):
- The counter scope IS the rendered prefix. When two records' tokens render to the
same prefix string (e.g.
{a}{b}for('AB','C')and('A','BC')) they also render the same visible number, so they share one counter to stay unique — the remedy for genuinely-distinct groups is an unambiguous format (a delimiter literal between variable tokens). - The sequence pad width is a MINIMUM; past it the number grows (
{000}→1000), it never wraps — matching mainstream autonumber semantics.
- Date tokens —
-
4c213c2: Master-detail "controlled by parent" permissions (ADR-0055).
A detail object can now declare
sharingModel: 'controlled_by_parent': its read/write access is derived from its master record, with no authored RLS.@objectstack/spec:controlled_by_parentadded to the authorableobject.sharingModelenum.@objectstack/plugin-security: reads injectmasterFK IN (accessible master ids)(resolved from the master's own RLS, reusing the existing filter machinery — zero RLS-compiler changes); by-id writes (insert/update/delete) to a detail now require edit access to its master, closing the #1994-class by-id hole for derived access.@objectstack/verify: related-record topological synthesis —deriveCrudCasesno longer skips objects with required relations; it builds the object dependency graph, orders it topologically, and threads real target ids, so relationship-dense objects (and the master-detail RLS proof) are verifiable. Honestblockedverdicts remain for required-reference cycles and external/missing targets.
v1 limits (per ADR-0055): the accessible-master id set is unbounded (large-tenant scale is a documented future limit), and master-detail chains are single-level (not transitively traversed).
-
2afb612: feat(security): resolve
current_user.emailin RLS owner policiesRLS
usingpredicates can now referencecurrent_user.email— a unique, human-readable, seedable owner anchor (owner = current_user.email). Previously the RLS compiler resolved onlycurrent_user.id/organization_id/roles/org_user_ids, so any owner-by-name/email predicate silently compiled to the deny sentinel (fail-closed → the user saw nothing). Email is sourced for free from the auth session (with a boundedsys_userfallback for the API-key path) and threaded onto theExecutionContextin both identity resolvers — the REST data path (rest-server) and the dispatcher path (resolve-execution-context).Display
nameis deliberately not exposed to RLS: names collide, and a collision on an ownership predicate is an access-control leak. Only unique identifiers (id,email) are resolvable.This makes owner-scoped row-level security work with seed data (no per-user ids needed) and, combined with
controlled_by_parent(ADR-0055), lets a master's owner scoping flow to its detail records. The example-showcase demonstrates it:showcase_invoicecarries anowneremail + an owner RLS policy, its lines are controlled-by-parent, and invoices/lines are seeded per owner. It also fixes the showcase's previously inert owner predicates (they used==andcurrent_user.name, neither of which the compiler accepts) to= current_user.email.
-
fa8964d: docs(spec): mark unenforced compliance/encryption/masking/RLS-config surface EXPERIMENTAL (ADR-0056 D8)
Per ADR-0049's enforce-or-remove gate (and ADR-0056 D8), the security-adjacent schemas that are parsed but have no runtime consumer now carry an explicit
⚠️ EXPERIMENTAL — NOT ENFORCEDheader so the no-op is visible to authors and the reference docs: GDPR/HIPAA/PCI compliance configs, field-level encryption, data masking, the unified security-context governance, and the globalRLSConfig/RLSAuditEvent(distinct from the ENFORCEDRowLevelSecurityPolicySchema, which is left untouched). No behaviour change — these were already inert; the marker makes the inertness honest rather than silent. -
a8e4f3b: Add the ADR-0054 "prove-it-runs" proof field + ratchet to the spec liveness gate. A
liveledger entry may now carry aproof— a reference (<file>#<proof-id>) to a dogfood test that asserts the property's runtime behavior. A bound high-riskliveproperty must carry a valid proof, validated statically by the liveness gate (the file exists and declares the matching@proof:tag). Four high-risk classes are bound this phase: field types (field.type), RLS (permission.rowLevelSecurity.using), flow nodes (flow.nodes.type), and analytics (dataset.dimensions.dateGranularity). Thedatasetmetadata type is now governed (newliveness/dataset.json). The authoritative high-risk-class list lives inscripts/liveness/proof-registry.mts; seeliveness/README.md.
-
1f88fd9: Converge the RLS contract with the reference compiler, and wire §7.3.1 dynamic membership.
- spec (docs): narrow
rls.zod.tsto the four expression forms the compiler actually implements —field = current_user.<prop>,field = 'literal',field IN (current_user.<array>), and1 = 1. Removed the over-promised surface (subqueries,AND/OR/NOT,LIKE/ILIKE, regex,ANY/ALL,NOT IN,IS NULL,NOW()/CURRENT_DATE) from the operator list, context-variable list, and@examplepolicies, and documented the fail-closed behaviour explicitly. - spec (schema):
ExecutionContextgainsrlsMembership?: Record<string, string[]>— a bag of pre-resolved dynamic-membership id arrays (team members, territory accounts, shared records) that the runtime stages so RLS can scope viafield IN (current_user.<key>)without subquery support. Generalizes the previously hard-codedorg_user_ids. - plugin-security:
RLSCompiler.compileFiltermergesrlsMembershipkeys into the user context (arrays only, never clobbering the namedid/organization_id/roles/org_user_idsfields), so §7.3.1 hierarchy- and sharing-based policies compile.compileExpressionnow recognizes1 = 1as always-true (empty filter), makingRLS.allowAllPolicygrant access instead of silently failing closed. Missing/empty membership sets still fail closed.
- spec (docs): narrow
-
1f88fd9: Add a transaction boundary to sandboxed hook/action bodies:
ctx.api.transaction(async () => { … }). Everyctx.apiread/write inside the callback runs in one driver transaction — committed when the callback returns, rolled back if it throws (or if the body leaves the transaction open at timeout). Guarded by the newapi.transactioncapability.- spec: new
api.transactioncapability token onHookBodyCapability. - objectql:
ScopedContextgains discretebeginTransaction()/commitTransaction(handle)/rollbackTransaction(handle)primitives. The handle is threaded explicitly through a child context (resolveTxhonors it ahead of the ambienttxStore), because the sandbox drives the body across many host event-loop turns where AsyncLocalStorage context does not survive. Degrades to non-transactional execution when the driver has no transaction support. - runtime: the QuickJS runner wires
ctx.api.transactionover three deferred-promise host leaves (begin/commit/rollback), routes in-transaction ops through the tx-scoped context, and rolls back a transaction the body left open before disposing the VM.
- spec: new
-
db02bd5: Fix dashboard time-series charts / "last N months" KPIs that filter or group by a
Field.datetimecolumn silently returning "No rows".The analytics
NativeSQLStrategycompiles dashboard relative-date tokens ({12_months_ago},{today}, …) to ISO date strings and binds them directly into raw SQL, bypassing the driver's own filter coercion. Under better-sqlite3 aField.datetimecolumn is stored as an INTEGER epoch (ms), soassessed_at >= '2025-06-18'became a TEXT-vs-INTEGER affinity compare that is always false — an empty result even though the rows exist.Field.datecolumns store ISO TEXT and were unaffected.The strategy now coerces a temporal comparand to the column's on-disk storage form via a new optional
StrategyContext.coerceTemporalFilterValuehook, wired to the driver's publicSqlDriver.temporalFilterValue(the single source of truth for the storage convention). Coercion is dialect-correct: SQLiteField.datetime→ epoch ms;Field.datetext and native-timestamp dialects (Postgres/MySQL) are left unchanged, so Postgres is never handed an epoch integer. Applied togte/lte/gt/lt/equals,in/notIn, and thedateRange/timeDimensionBETWEENpath. -
641675d: Add
*Inputauthoring-type aliases (DatasourceInput,ConnectorInput,SharingRuleInput,JobInput,WebhookInput,EmailTemplateDefinitionInput,RoleInput,PermissionSetInput,ObjectExtensionInput) alongside the existingFieldInput/ActionInput/ReportInput/PortalInputconvention. These arez.input<typeof XSchema>aliases so authored literals keep.default()fields optional and accept CEL/Expression string shorthands — matching howdefineX()helpers already accept input. No runtime change. -
94e9040: fix(spec): declare the extended Gantt config fields the renderer actually reads
GanttConfigSchemaonly declared the 5 core timeline fields as a plainz.object(no passthrough), so every other field the Gantt renderer consumes —parentField/typeField(two-level summary→step hierarchy),colorField,groupByField,tooltipFields,baselineStartField/baselineEndField,resourceView/assigneeField/effortField/capacity,quickFilters,autoZoomToFilter— was silently stripped by.parse()on both the compile-time protocol check and the runtimeGET /api/v1/meta/view/:objectre-validation. With the keys gone before render, the Gantt degraded to a flat list (no parent/child rows, no summary bars, no expand/collapse). These fields are now declared explicitly (with descriptions), so the renderer contract round-trips through the spec instead of requiring downstream patches.
-
84249a4: feat(action):
undoableflag on the UI Action schemaSingle-record update actions can declare
undoable: true. The runtime captures the record's prior field values and offers an "Undo" affordance on the success toast (backed by the client UndoManager). Pairs with the objectui runtime that honours it. Also documents that conditionalvisible/disabledCEL predicates are evaluated by the action renderers (used here to hide an action when it no longer applies, e.g. Convert Lead on an already-converted lead). -
11af299: feat(runtime): resolve a reference timezone onto ExecutionContext (ADR-0053 Phase 2 foundation)
Adds
ExecutionContext.timezone(optional IANA zone) and resolves it once per request inresolveExecutionContext, with precedence user preference → org default →UTC:- User override:
sys_user_preferencerow(user_id, key='timezone'). - Org default: the tenant-scoped
sys_setting(namespace='localization', key='timezone', scope='tenant')— one org per physical tenant (ADR-0002), so no tenant_id filter is needed. - An invalid IANA zone is ignored and resolution falls through; every read is defensive and never blocks auth.
This is pure plumbing with no behavior change: nothing reads
ctx.timezoneyet, and an absent value resolves toUTC(today's behavior). It is the foundation the rest of ADR-0053 Phase 2 consumes — tz-awaretoday()/daysFromNow()(#1980), datetime rendering (#1981), and analytics bucketing (#1982). A discoverablelocalizationsettings manifest for the org default is a follow-up; the resolver already reads the row if present.Part of #1978.
- User override:
-
d5774b5: fix(spec):
Field.rating/Field.vectorbuilders emit live props instead of dead onesThe
Field.rating(n)andField.vector(n)builders emitted properties the spec-liveness ledger classifies as dead (silent runtime no-ops), so every field authored through them tripped theliveness-dead-propertyauthor lint:Field.rating(n)emittedmaxRating, but the rating renderer reads the flatmaxprop (RatingField.tsx:13). The builder now emitsmax.Field.vector(n)emitted a nestedvectorConfigblock, but the renderer reads the flatdimensionssibling (VectorField.tsx:11) and nothing consumesvectorConfig(no vector-index DDL). The builder now emits the flatdimensions.
dimensionsis also promoted to a declared, live top-levelFieldSchemaproperty. It was previously only valid nested insidevectorConfig, so a flatdimensionsauthored by hand was silently stripped during compile (Zod drops unknown keys) — the renderer then saw no dimensionality. It now survives compilation and is governed by the liveness gate.maxRatingandvectorConfigremain accepted by the schema (still classifieddead+authorWarn) for back-compat, so hand-authored usages still surface the advisory warning rather than type-erroring. -
134043a: feat(automation): declarative screen-flow completion/error messages + action
errorMessageA screen flow can now declare
successMessage/errorMessage(FlowSchema). The engine surfaces them on the terminalAutomationResult(successMessageon success,errorMessageon failure), so the UI flow-runner shows a meaningful toast instead of a generic "Done" / the raw error — no manual "success screen" node needed. The CRM convert-lead wizard sets a friendly completion message.Also exposes
errorMessageon the UI Action schema. The runtime (ActionRunner) already honoured it; it just wasn't declarable in the spec — closing a spec↔runtime gap so authors can set a friendly failure toast. -
9afeb2d: feat(settings):
localizationsettings — platform default timezone, language & formats (ADR-0053 Phase 2)Adds a
localizationSettingsManifest, the missing keystone that makes the Phase 2 reference-timezone actually configurable end-to-end. One declaration gives the full settings stack for free: platform built-in default →global→tenantcascade, a permission-gated settings page, and i18n.Keys (organization-level; per-user overrides intentionally out of scope for v1):
timezone(UTC),locale(en-US),default_country,date_format,time_format,number_format,first_day_of_week,currency(USD),fiscal_year_start. Benchmarked against Salesforce/Workday "Company Information + Locale".Resolver 收编 —
resolveExecutionContextnow resolvestimezoneandlocalefrom thelocalizationsettings via thesettingsservice (canonical 4-tier cascade), falling back to a direct tenant-scopedsys_settingread, thenUTC/en-US. This replaces the hand-rolledsys_user_preference+ tenant-onlysys_settingpath from #1978 (which bypassed the settings abstraction and is dropped along with the per-user tier). NewExecutionContext.locale.Consumer wiring — analytics date bucketing now picks up the resolved org timezone:
DatasetExecutorthreadsExecutionContext.timezoneinto the query (precedence: explicit selection tz → request tz → UTC), so #1982's tz-aware buckets fire for a configured org without callers passing a zone. Formulatoday()/datetimewere already wired (#1979/#1980).Email
datetimerendering (SendTemplateInput.timezone, shipped in #1981) is intentionally not wired here: the only currentsendTemplatecallers are pre-session auth emails with no org context; business-notification callers can pass the zone when they appear. -
6bec07e: feat(automation): object-form screen-flow steps
A
screennode that declaresconfig.objectNamenow renders the named object's FULL create/edit form (including inline master-detail child grids) instead of a flat field list. The node emits anobject-formScreenSpec(kind/objectName/mode/recordId/defaults/idVariable); the client renders the real ObjectForm, persists the record (and its children, atomically), and resumes the run with the saved id bound toidVariableso a later step can reference it — e.g. a lead-conversion wizard: a full Customer step, then a full Opportunity-with-line-items step.- spec:
ScreenSpecgainskind/objectName/mode/recordId/defaults/idVariable. - service-automation: the
screenexecutor emits object-form specs and now interpolatestitle/description/fielddefaultValue/object-formdefaultsagainst live flow variables (the engine does not pre-interpolate node config).
- spec:
-
601cc11: feat(analytics): timezone-aware date bucketing (ADR-0053 Phase 2)
Analytics day/week/month/quarter/year buckets now resolve on a reference timezone's calendar days, so a row near a tz day-boundary lands in the bucket a user in that zone would expect — identically on SQLite and Postgres.
Per ADR-0053 decision D2, bucketing is done in-memory, uniformly for non-UTC zones rather than emitting dialect-specific
date_trunc … AT TIME ZONE(SQLite has no tz database and MySQL needs tz tables loaded, so splitting by dialect would shift bucket boundaries for the same data).engine.aggregate({ timezone })therefore forces the in-memory aggregation path when a non-UTC reference tz is set — the date-rangewherestill goes to the driver, so only matching rows are fetched. UTC / unset keeps the native driver fast path unchanged.- New shared
calendarPartsInTz/calendarPartsInTzOrUtcutil in@objectstack/core(DST-safe viaIntl.DateTimeFormat, never hand-rolled offset math; falls back to UTC for an unset/'UTC'/invalid zone). EngineAggregateOptionsand the analyticsexecuteAggregatebridge /ObjectQLStrategythread the reference timezone (sourced from the dataset selection /ExecutionContext) through toapplyInMemoryAggregation→bucketDateValue, and the draft-preview evaluator'sbucketDate.formatDateBucket(dimension labels) stays UTC-only by design: it re-labels values that were already bucketed upstream, so re-applying a timezone there would shift a correct bucket by a day.
- New shared
-
575448d: feat(formula,email): render
datetimein a reference timezone (ADR-0053 Phase 2)datetimetemplate holes now render in a reference timezone's wall-clock when one is supplied, at the presentation boundary — storage stays UTC.- Formula template engine — the
datetimeformatter takes the reference timezone fromEvalContext.timezone(threaded in #1980) and passes it toIntl.DateTimeFormat.{{ ts | datetime }}renders in that zone;{{ ts | datetime:iso }}stays UTC (machine-readable). Calendar-daydaterendering is intentionally unchanged (tz-naive — aField.datehas no zone). New exportedformatValue(name, value, arg, { locale, timeZone })makes the whitelisted formatters reusable outside the full CEL template engine. - Email pipeline —
plugin-email's renderer previously bypassed the formatter pipeline (String()only), so a datetime went out as raw ISO. Email holes now accept the shared formula formatters —{{ order.total | currency }},{{ ts | datetime }}— reusingformatValue(single source of truth), while keeping the engine's HTML-escaping and{{{ }}}raw-output semantics.SendTemplateInput.timezone(mirroring the existinglocale) flows into rendering so an email's datetime shows the recipient's wall-clock.
- Formula template engine — the
-
90108e0: feat(cli): liveness author-warning lint — close the spec-liveness loop on the author side.
The liveness ledgers already classify every authorable property live/experimental/dead with evidence, and the CI gate enforces classification completeness — but that knowledge never reached the person (very often an AI) writing the metadata. The new
compilelint (lint-liveness-properties.ts) reads the ledgers and emits an advisory warning when an authored object/field sets a property that is misleading at runtime — e.g.object.enable.feeds(no feed runtime; comments live on sys_comment),object.versioning(no versioning engine),field.columnName(driver ignores it; column == field key),field.maxRating/vectorConfig(renderer reads a different key) — each with a corrective hint toward the supported alternative. Never fails the build (advisory only), consistent with the existing flow anti-pattern lint.Signal-over-noise by design: warnings are opt-in per ledger entry via a new
authorWarn/authorHintannotation (plusexperimentalentries warn by default). Booleans warn only when set truthy, and onlydefault(false)flags are marked, so schema defaults (enable.trash,enable.searchable) never trip it. Coverage grows by annotating more ledger entries, not by changing lint code; today it coversobject(incl.enable.*) andfield.@objectstack/spec: ledger entries gain optionalauthorWarn/authorHint;liveness/is now shipped in the packagefilesso the CLI can read it. Seeded annotations on the misleading object capability flags + aspirational blocks and the misleading dead field props. No schema/runtime change.
- 97c55b3: chore(spec): prune 15 dead field display-config properties (ADR-0049 / dead-surface plan). Removes
FieldSchemaenhanced-type display knobs that had no runtime reader and no renderer consumer (dead in both layers per the field liveness audit): codetheme/lineNumbers, ratingallowHalf, locationdisplayMap/allowGeocoding, addressaddressFormat, colorcolorFormat/allowAlpha/presetColors, slidershowValue/marks, barcode/qrbarcodeFormat/qrErrorCorrection/displayValue/allowScanning. The wired knobs (language,maxRating,step) and the functional nested configs (currencyConfig/vectorConfig/fileAttachmentConfig) are kept. Field types are unchanged; only unused optional config props are removed. Narrows the false spec surface (narrow-and-true). - 1b1f490: chore(spec): prune 7 dead field governance/compliance properties (dead-surface plan, P0/P2). Removes
FieldSchemaprops that implied data-protection/governance behavior but had no runtime consumer — false promises (the real at-rest channel istype: 'secret'):encryptionConfig,maskingRule,auditTrail,cached,dataQuality,writeRequiresMasterRead,trackFeedHistory. Also drops the now-unusedEncryptionConfigSchema/MaskingRuleSchemaimports. KeptcaseSensitiveanddependencies(potentially functional — conservative). Field types unchanged.
-
71578f2: feat(book): documentation navigation as a
bookelement — spine + derived membership (ADR-0046 §6)Adds the
bookmetadata element: a navigation spine (ordered groups +audience+ identity) whose membership is derived by rule (includeglob/tag) plus optional per-docorder/group, never a central array. This keeps AI authoring create-and-forget (no central-array read-modify-write) and runtime overlay merge-safe (RFC 7396 treats arrays atomically).BookSchema+resolveBookTree()derived-membership resolver +defineBook()+ additivedoc.order/doc.group.- Register
bookas a render-time metadata type (allowOrgOverride: true); wire it through the runtime type enumerations (PLURAL_TO_SINGULAR, engine registration, artifact field map, type-schema map). - REST
GET /meta/book/:name/treeresolves the tree; read-layeraudiencegating (public≡ anonymous;org/{profile}require sign-in).
- d1e930a: feat(spec): model action-param translations in TranslationData (
_actions.params) so action param label/helpText/placeholder/options can be localized via the keys+bundles path. Additive and optional — existing bundles unaffected. - 5e3a301: fix(spec): surface hook
retryPolicyandtimeoutin the Studio hook designer form (Execution section), completing schema coverage. - 5db2742: chore(spec): mark every PolicySchema property
[EXPERIMENTAL — not enforced](ADR-0049, #1882). PolicySchema (password/network/session/audit +forceMfa, IP allow-list, retention) is parsed but has no runtime consumer —better-authruns hardcoded defaults. The per-property markers make the no-op explicit in the generated reference docs (previouslyforceMfaread "Require 2FA for all users" with no caveat — a false-compliance signal) and to the spec-liveness gate, which now classifies themexperimentalrather thandead. Description-only; no behaviour change.
-
ee72aae: fix(spec): render action
bodyas a composite editor (language + source) instead of a flat code fieldAn action's
bodyis a discriminated union (HookBodySchema), the same shape hooks use, butaction.form.tsmapped the whole field to{ widget: 'code' }, so the Studio inspector fed the union object to a single JS editor and rendered[object Object]. The layout now mirrors the workinghook.form.ts: a composite with alanguageselect, asourcecode editor, and the L2-only capability/timeout knobs.
-
d08551c: feat(ADR-0046): per-locale documentation content (doc i18n)
Docs can now ship localized bodies. Authors add sibling locale-variant files
src/docs/<name>.<locale>.md(e.g.crm_lead_guide.zh.md,..pt-BR.md) next to the base<name>.md; the base stays the default and the fallback. Flatness is preserved — variants are flat siblings, not subdirectories.- spec:
DocSchemagains an optionaltranslationsmap (locale → {label?, description?, content}) plusresolveDocLocale(doc, locale), which collapses a doc to the best-matching locale (exact → primary subtagzh-CN→zh→ base) with per-field fallback and strips thetranslationsmap. - cli (collect-docs): variant files are folded into the base doc's
translations; orphan/duplicate variants and the v1 MDX/image bans are linted on variant content too. - rest:
/meta/doc(list + single) resolves the request locale from the existingAccept-Language/?localenegotiation, returns one localized body, and never ships thetranslationsmap. Doc detail bypasses the response cache so a language switch can't return a stale-locale body. - setup / studio: the built-in overview docs now ship
zhtranslations (TS-first inlinetranslations), so a Chinese console renders Chinese docs.
The console already sends the active UI language as
Accept-Language, so doc content localizes on a language switch with no client change. - spec:
-
707aeed: ui(page.form): sourceView is a view picker; hide template on list pages
interfaceConfig.sourceViewnow declareswidget: 'view-ref'+dependsOn: 'source'so the page editor renders a dropdown of the source object's views instead of a free-text input (where an author could type a non-existent view name). The objectuiview-refwidget reads the source object's views; until it ships, the field degrades to the existing text input.- The
templatefield is now hidden fortype == 'list'(visibleOn: "data.type != 'list'"). A list/interface page renders via InterfaceListPage and ignores the region template, so showing the field only added noise — same rationale as the already-hidden Data Context / Layout sections.
-
7a103d4: ui(page.form): icon field uses the searchable icon-picker widget
The Basics →
iconfield now carrieswidget: 'icon', so the metadata-admin form renders a searchable Lucide icon picker (preview + name) instead of a raw text input where authors had to type an exact icon name. Mirrors the existingview-ref/filter-modewidget hints; the picker ships in@object-ui/app-shelland is reusable for app/object icon fields. -
4b01250: ui(page): page
typeis the page kind, not a visualizationRemoved
grid/kanban/calendar/gallery/timelinefromPageTypeSchema. They are visualizations of alist(interface) page — configured viainterfaceConfig.appearance.allowedVisualizationsand switched at runtime — never distinct page kinds. The runtime never branched on them as page types (it always read the visualization frominterfaceConfig), so they only misled authors (e.g. selecting page type "kanban" did nothing).VisualizationTypeSchemais unchanged and remains the home for those values.The roadmap interface kinds (
dashboard,form,record_detail,record_review,overview,blank) stay valid in the schema but the page authoring form (page.form.ts) now offers only the kinds with a dedicated renderer —list,record,home,app,utility— with explicit labels, so the dropdown stops presenting dead options.
-
060467a: feat(ADR-0046): add optional
descriptionto package docsA doc can now carry a one-line
description(frontmatterdescription:), giving the natural minimal model: title / summary / body.DocSchemagains an optionaldescription;os buildreads it from frontmatter. It travels in theGET /meta/doclist response (unlikecontent, which the list omits), so a docs portal can show summaries without fetching each body. Example docs (app-showcase, app-todo) updated.Also records the deferred-to-P3 design for doc tags in ADR-0046: tags are keys (i18n-resolved, never display strings), with a small protocol core vocabulary plus namespace-prefixed package tags — not a field to bolt on early.
-
0856476: feat(metadata): package-scoped single-item resolution via
?package=(ADR-0048)A single-item metadata GET (
/meta/:type/:name?package=<id>) now resolves package-scoped (prefer-local): when two installed packages ship an item of the sametype/name, the requester's own package wins. Previously only the list endpoint was package-aware; a single-item fetch was context-free, so a cross-package collision always resolved to whichever package registered first.The fix threads
packageIdend-to-end:@objectstack/rest— the cacheable single-item path calledgetMetaItemCached(ETag keyed on type+name only) and dropped?package=. A?package=read now bypasses that cache and takes the disambiguatinggetMetaItem(type, name, packageId)path, so two same-named items never share one cache entry.@objectstack/objectql—protocol.getMetaItemforwardspackageIdto the overlay query (sys_metadata.package_id),MetadataFacade.get, andregistry.getItem;MetadataFacade.getgained an optionalcurrentPackageId.@objectstack/runtime— the parallel HTTP dispatcher threads?package=too.
This lets the doc viewer (
/apps/:packageId/docs/:name) resolve one doc scoped to its app, sodocnames no longer need a namespace prefix for uniqueness (the prefix becomes a recommended convention, likepage/dashboard/report);doc.zoddoc-comments updated accordingly. -
b678d8c: feat(spec): page form filter-mode widget + ADR-0047 §3.4a (omit-is-none)
The Interface section's
interfaceConfigcomposite now lists its sub-fields explicitly souserFilterscan use the dedicatedfilter-modeselector widget (None / Tabs / Dropdown, objectui). An unknown widget name degrades gracefully to the prior composite rendering, so this is independently mergeable.ADR-0047 §3.4a records the design decision: "no filter bar" is the ABSENCE of
userFilters, not a literalelement: 'none'— presence and style are orthogonal axes, keeping declarative metadata and overlay diffs clean. TheuserFilterselement'toggle'is deprecated (kept in the enum for back-compat; authoring offers None/Tabs/Dropdown only, Airtable parity). -
b678d8c: feat(spec): ADR-0047 — list pages hide region/data-context, interface section prominent
Reorganizes the page form (
page.form.ts) so interface/list pages get a lean, relevant panel instead of the generic page-form dump:- Data Context + Layout sections gain
visibleOndata.type != 'list'(region designer / page object don't apply to a list surface). - Interface section becomes primary content (
collapsed: false, named for i18n). interfaceConfigsub-fields reordered (common first, rare last);sourcegets theref:objectpicker;sourceView/userActions/etc. gain helpText.typefield helpText notes'list'= interface page.
- Data Context + Layout sections gain
-
b678d8c: fix(service-ai): resolve the current object for AI chat across languages
The console assistant reported "can't find the X object" when asked to analyse the object on the current page — most visibly for non-English prompts. Three compounding gaps fixed:
SchemaRetriever.tokenise()dropped all CJK text, so a Chinese request yielded zero terms; it now emits CJK single-char + bigram terms.- Nothing fed the current object's schema to the agent, so "this object" could
not be resolved without a lucky keyword hit.
AgentRuntime.buildContextSchema Messages()now injects the current object's schema into the system prompt and both chat routes call it. ToolExecutionContext(and theai-servicespec contract) gainscurrentObjectName/currentViewName; routes thread them through andquery_datafalls back to the current object when keyword retrieval is empty (so the open edition, which lacksdescribe_object/list_objects, still resolves the page's object).
-
1ada658: ADR-0046 P1: package documentation as metadata. New
docmetadata element — flat Markdown files undersrc/docs/*.mdcompile intodocs: DocSchema[]on the stack and register like any other metadata.- spec:
DocSchema({ name, label?, content }) insystem/,StackDefinition.docs,docinMetadataTypeSchema+ type registry (inert data, runtime-creatable) + canonical schema map,docs → docplural mapping. - cli:
os buildcollects flatsrc/docs/*.md(frontmattertitle:/first#heading → label) and enforces the ADR lint — flat directory, namespace-prefixed snake_case names, namespace required when docs ship, MDX/image ban, same-package relative-link resolution. Same rules surface inos lint. - objectql:
docsjoins the generic metadata registration loop (manifest + nested plugins). - runtime: docs count as app payload;
GET /metadata/doclist responses omitcontentby default (?include=contentopts in) so unbounded manuals stay off hot paths.
- spec:
-
290f631: ADR-0044 flow-level send-back-for-revision (#1744). The approval node gains a third flow movement beyond approve/reject:
sendBack()finalizes the pending request asreturned(newApprovalStatus), resumes the run down itsreviseedge to a wait point where the record lock releases, and the submitter'sresubmit()re-enters the approval node over a declared back-edge, opening the next round's request (fresh approver slate, re-locked,roundstamped via the config snapshot). Engine:FlowEdgeSchema.typegains'back'— cycle validation now requires the graph minus back-edges to be a DAG (unmarked cycles still rejected), node re-entry overwrites outputs/appends steps, a 100-re-entry runaway guard backstops misauthored loops, andcancelRun(runId, reason)lands as the first run-cancel primitive (recall crossing a revise window cancels the parked run).maxRevisions(default 3) on the approval node config auto-rejects send-backs past the budget. REST:POST /approvals/requests/:id/reviseand/resubmit. Audit kindsrevise/resubmitjoinApprovalActionKindand thesys_approval_actionenum. -
50b7b47: Approvals server-side pagination + search pushdown (#1745).
listRequestsacceptsq/limit/offset— free-text search pushes into the engine query as an$orof$containsterms (thepayload_jsonsnapshot carries record titles, so titles match without a join), and the page window pushes down whenever the filter is fully pushable; approver/status-array filters still post-filter their bounded scan and window in memory (the documented residual until the approver join-table follow-up). NewcountRequestsreturns the unwindowed total (enginecountwhen pushable). REST:GET /approvals/requestsgainsq/limit/offsetand returns{data, total}when paging. -
f15d6f6: ADR-0042 SLA auto-escalation + ADR-0041 mechanical landing. plugin-approvals now owns a jobs-backed escalation scanner (
runEscalations, interval jobapprovals-sla-escalation+ boot catch-up): overdue pending requests escalate at most once (theescalateaudit row is the idempotency marker, written audit-first) executing the node'sescalation.action— notify / reassign-to-escalateTo/ auto_approve / auto_reject as the reserved actorsystem:sla. The trigger packages drop theirplugin-prefix (@objectstack/trigger-record-change,@objectstack/trigger-schedule) per ADR-0041, andActionDescriptorgains an optionalmaturity: 'ga' | 'beta' | 'reserved'field so designers can grey out contract-ahead-of-runtime surfaces. -
f8684ea: Approvals thread interactions — the collaboration layer between submit and decide.
reassign()hands a pending-approver slot to someone else (audit-first ordering, new approver notified via the optionalmessagingservice),remind()nudges every pending approver with a 4h per-request throttle (THROTTLED→ HTTP 429),requestInfo()sends a request back to the submitter for more material while it stays pending, andcomment()adds free-form thread replies. Rows exposesla_due_at(created_at + escalation.timeoutHours, display-only) and single reads attachflow_steps(the owning flow's approval trunk with done/current/upcoming states). REST grows the four matching POST routes; thesys_approval_action.actionenum gains the new kinds. -
b4765be: Server-side totals for matrix reports (#1753).
queryDatasetselections accepttotals: { groupings: string[][] }— each grouping a subset ofselection.dimensionsto additionally aggregate by ([]= grand total); the marginal rows come back onAnalyticsResult.totalsin request order. Each subtotal/grand total re-runs the full executor pipeline (measure-scoped filters, derived measures, compareTo) grouped only by that subset, so totals use each measure's true aggregate over the underlying rows — anavgtotal is the average of all rows, never an average of bucket averages (the ADR-0021 line that forbids client-side re-aggregation). Dimension display labels resolve on totals rows the same as the primary grid. A matrix report renderer asks for{ groupings: [rowDims, columnDims, []] }and renders the supplied totals row/column.
- 3219191: ADR-0043 actionable approval links (#1743).
remind()now fans out per approver: every concrete identity gets its own single-use approve/reject links in the notification payload. Tokens are 256-bit, stored as SHA-256 hashes only (sys_approval_token), scoped to one request + action + approver, 72h TTL, consumed-before-decide (replay burns), and re-validated at redemption against the live request (decided/recalled/reassigned ⇒ dead link). The plugin mounts a session-less bilingual confirm page atGET /api/v1/approvals/act(renders only — mail-gateway prefetch safe) and redeems exclusively on thePOST, auditing the decision as the bound approver.
- 2f57b75: Approvals display contract v2 — no raw identifiers reach a business reviewer. The inbox enrichment pass now resolves the three remaining id leaks:
payload_displayresolves lookup/master_detail foreign keys in the snapshot to the referenced record's display title (batched one query per object),pending_approver_namesresolves user-id approvers viasys_user(id or email;role:<r>literals stay as-is),object_labelrides the target object's schema label on the row, andlistActionsrows carryactor_nameso the audit timeline never shows an id. - 2f57b75: ADR-0040: unify the platform assistant. The default
data_chatagent becomes the single platform assistant carrying both the data and authoring registers — the end user never picks an agent. It gains themetadata_authoringandsolution_designskills (registered by the cloud AI Studio plugin; data-only deployments degrade gracefully as the skill registry ignores unresolved names), an intent preamble that classifies build/change vs data intent first and applies that register's discipline without mixing registers or narrating failures, an 'Assistant' persona, temperature 0.2, a guardrail blocklist union minusalter_schema/drop_table(the build register is draft-gated schema work per ADR-0033), a 60s execution budget, and react ×10 planning with replan.
- b9062c9: ADR-0021 D2:
Reportgainscolumns(dimension names across — amatrixreport pivotsrows×columnswithvaluesin the cells; also on joined blocks) anddrilldown(boolean, defaulttrue— click an aggregated row/cell to open the underlying records).reportFormsurfaces both in the Dataset binding section (columnsvisible for matrix only).
- 1817845: reportForm now matches the 9.0 dataset-bound ReportSchema (ADR-0021): the authoring form declares
dataset/values/rows/runtimeFilterinstead of the removed query-form fields (objectName/columns/groupingsDown/groupingsAcross/filter), so editors no longer offer fields the schema strips at parse time.
-
4c3f693: ADR-0021 single-form cutover (BREAKING): the inline analytics author surface is removed — every dashboard widget, report, and list-chart must now bind a semantic
datasetand select dimensions/measures by name.Removed from the spec:
- DashboardWidget —
object,categoryField,categoryGranularity,valueField,aggregate,measures(and theWidgetMeasureschema/type).dataset+valuesare now required;filteris the presentation-scope runtimeFilter;dimensions/compareToare retained. - Report — top-level (and joined-block)
objectName,columns,groupingsDown,groupingsAcross,filter. A non-joined report now requiresdataset+values;rowsare the dimensions. - ListChart —
xAxisField,yAxisFields,aggregation,groupByField.dataset+valuesare now required.
Migration: replace the inline query with a
defineDataset(...)and reference it by name. A flat record listing (the formertabularreport / inline list) is an object-bound ListView (ADR-0017), not an analytics dataset. Seedocs/adr/0021-analytics-dataset-semantic-layer.mdand thecontent/docs/guides/analytics-datasets.mdxguide. - DashboardWidget —
-
1c83ee8: BREAKING:
ChartTypeSchemadrops 8 variant types that only rendered as their base chart, so the taxonomy now advertises only families the renderer draws distinctly.Removed:
grouped-bar,stacked-bar,bi-polar-bar(→ bar — no multi-series grouping/stacking),stacked-area(→ area),step-line,spline(→ line),pyramid(→ funnel),bubble(→ scatter — no size encoding).Kept: bar / horizontal-bar / column, line / area, pie / donut / funnel, scatter, treemap / sankey, radar, table / pivot, and the single-value performance family (metric / kpi / gauge / solid-gauge / bullet — these render an honest value today and gain a dial when a gauge renderer lands).
Migration: a widget/series using a removed type should switch to its base (
stacked-bar→bar,spline→line,pyramid→funnel,bubble→scatter, etc.). These can return via an opt-in renderer once a real renderer + data model backs them.
-
0bf39f1:
queryDatasetnow carries each measure's displaylabelandformaton the resultfields, so presentations can show "Tasks" / "$616,000" instead of the raw measure name "task_count" / "616000".AnalyticsResult.fields[]gains optionallabel?andformat?.- The dataset executor enriches measure columns from the dataset's measure
definitions (matching
<name>and<name>__compare).
The format can't be baked into the numeric row value (charts need the raw number), so the renderer applies it at display time.
-
f533f42: Settings namespace environment overrides now use the canonical ObjectStack
OS_<NAMESPACE>_<KEY>form, with no unprefixed aliases. For example,ai.openai_base_urlis nowOS_AI_OPENAI_BASE_URL, andfeature_flags.ai_enabledis nowOS_FEATURE_FLAGS_AI_ENABLED.The AI service now treats a stored or env-locked
provider=memorysetting as an explicit override, while the manifest default still leaves boot-time provider auto-detection intact.The auth plugin now binds the
authsettings namespace to better-auth runtime configuration, exposes an extension hook for provider packages, and includes a basic Google sign-in implementation configured either in Setup → Authentication or by deployment-levelGOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRET.
-
b990b89: fix(autonumber): one owner for autonumber generation — the persistent driver sequence (#1603)
Autonumber values were generated in TWO places: the SQL driver's persistent, atomic
_objectstack_sequencestable AND a non-persistent in-memory counter in the ObjectQL engine. Because the engine pre-filled the field BEFORE calling the driver, the driver always saw a value already set and skipped — so the persistent sequence was effectively dead code, and a multi-instance / post-restart deployment could mint duplicate numbers from the in-memory counter.This makes generation single-owner:
-
@objectstack/spec—DriverCapabilitiesgains an optionalautonumberflag: "driver natively generates persistent autonumber/sequence values". -
@objectstack/driver-sql— advertisessupports.autonumber = true.bulkCreate()now fills autonumber fields too (previously onlycreate()/upsert()did), so bulk inserts also draw from the persistent sequence. Field parsing now honors either the spec-canonicalautonumberFormatkey OR theformatshorthand (both appear in metadata). -
@objectstack/objectql— when the driver advertises native autonumber support, the engine NO LONGER pre-fills (it defers entirely to the persistent driver sequence as the single source of truth). For drivers without native support (memory, mongodb) the in-memory fallback is unchanged. The fallback also now reads eitherautonumberFormatorformat. Record-validation exemptsautonumberfields from therequiredcheck — the value is runtime-owned and assigned after validation, so a required record number is never rejected as "missing".
No metadata changes required. Existing data is respected: the driver bootstraps each sequence from the current max numeric tail on first use.
-
-
99111ec: Field-level conditional rules (CEL):
visibleWhen/readonlyWhen/requiredWhen, enforced server-side.Add three CEL-predicate field props (over
record) evaluated on both sides. Spec:visibleWhen/readonlyWhen/requiredWhen(requiredWhencanonical;conditionalRequiredkept as a back-compat alias). Server (objectql): the validator now enforcesrequiredWhen/conditionalRequiredover the merged record (so the rule can't be bypassed by a direct API write), and the update path ignores writes to a field whosereadonlyWhenis TRUE (keeps the persisted value).needsPriorRecordaccounts for conditional fields so the prior record is fetched on update. -
d5a8161: feat(spec): resilientFetch — timeout + backoff for outbound HTTP (P1-1)
Outbound calls in the connectors/embedder were naked
fetchwith no timeout or retry, so a slow or rate-limited external API could hang an agent turn with no recovery.New shared
resilientFetch(@objectstack/spec/shared):- per-attempt timeout via
AbortController(default 30s); - exponential backoff with jitter, up to 3 attempts, on network errors / 429 / 5xx;
- honours a
Retry-Afterheader on 429; - never retries a caller-initiated abort (intentional cancellation).
Wired into
connector-rest,connector-slack, andembedder-openai.connector-mcptalks through the MCP SDK transport, so it gets a 30s per-requesttimeoutoncallTool/listToolsinstead.A stateful per-host circuit breaker is deliberately left as a follow-up: timeout + backoff already removes the hang/no-recovery risk.
- per-attempt timeout via
-
5cf1f1b: feat(spec):
inlineEditon relationship fields for declarative master-detailA
master_detail/lookupfield can now declareinlineEdit: true(plus optionalinlineTitle/inlineColumns/inlineAmountField) to mean "these child records are entered/edited inline within the parent's form". The intent lives in the data model: the parent's standard create/edit form then renders an atomic master-detail form (object fields + an editable child grid) with no form view config and no bespoke page. Use for line-item/composition children; leave off for associations (comments, attachments). Renderer support is in objectui. -
9ef89d4: feat(spec):
FormViewSchema.subformsfor config-driven master-detailA form view can now declare inline child collections via
subforms, so the standard create/edit form for an object can render as a master-detail form (object fields on top, an editable child grid below, persisted atomically) without a bespoke page. Each entry needs onlychildObject; the relationship FK and grid columns are derived from the child object's metadata (override viarelationshipField/columns). Renderer support: ObjectForm already renderssubforms(objectui), and the ObjectView form path passes them through. -
9e2e229: feat(objectql): compute roll-up
summaryfields server-sideThe
summaryfield type was declared in the spec but never computed — its value stayed empty. ObjectQL now recomputes roll-up summaries automatically: a parent field whosesummaryOperationsaggregates (count/sum/min/max/avg) a field across child records is recalculated whenever a child is inserted, updated, or deleted.-
@objectstack/spec—summaryOperationsgains an optionalrelationshipField(the child→parent FK). When omitted the engine auto-detects it from the child'slookup/master_detailfield whosereferencepoints back at the parent; set it explicitly only when the child has more than one such reference. -
@objectstack/objectql— afterafterInsert/afterUpdate/afterDeleteon a child object, the engine finds the affected parent (from the child's FK, plus the prior FK on update/delete so a re-parented child updates both), re-aggregates the child collection, and writes the result onto the parent's summary field. It runs in the caller's execution context, so when a transaction is open (e.g. the cross-object/api/v1/batch) the rollup commits atomically with the child writes. A small index of child→summary descriptors is built lazily from the registry and invalidated on package registration.
Empty collections roll up to
0forcount/sumandnullformin/max/avg. This lets master-detail forms stop computing parent totals on the client — the server is now the single source of truth. -
-
a46c017: feat(ai): actions opt in to being AI tools via an
ai:block (ADR-0011)Realigns ADR-0011 with its original opt-in design. An Action becomes an AI-callable tool only when its metadata sets
ai.exposed: true, which requires an explicit, LLM-facingai.description(≥40 chars, distinct from the UIlabel). There is no heuristic auto-exposure and no description derived from the label — a clean break from the first implementation's opt-outaiExposedflag, which is removed (no compatibility shim; the platform has not shipped).The
ai:block also carriescategory,paramHints(per-parameter JSON-Schema refinement),outputSchema(summarised into the tool description for chaining), andrequiresConfirmation(overrides the destructive-action HITL default).AIToolDefinitionis extended to carrycategory/outputSchema/objectName/requiresConfirmation. The@objectstack/service-aibridge (action-tools.ts) now gates on opt-in, mergesparamHints, and emits a lint warning when an exposed destructive-looking action asserts itself safe viaai.requiresConfirmation: false. -
3306d2f: feat(automation): surface structured-region body steps in run observability (#1505)
loop/parallel/try_catchpreviously ran their body, branch, and handler regions against a region-local step log that was discarded — run logs (listRuns/getRun) showed the container as a single opaque step, hiding the per-iteration / per-branch steps that actually executed.AutomationEngine.runRegion()now returns its body steps, and the container node folds them into the parent run log via a newNodeExecutionResult.childStepsfield. Each surfaced step is tagged with its immediate container via three new optional fields onExecutionStepLogSchema(and the engine'sStepLogEntry):parentNodeId— the enclosingloop/parallel/try_catchnodeiteration— zero-based loop iteration or parallel branch indexregionKind—loop-body|parallel-branch|try|catch
Tagging fills only fields left undefined, so nested regions keep each step's innermost container. A failed try-region attempt's partial steps are still not surfaced (preserving
try_catchretry semantics). Fully additive — existing run logs and consumers are unaffected. -
bc44195: chore(automation): retire the
workflow_ruleauthoring paradigm (ADR-0018 M5 dropped)ADR-0019 already removed the Workflow-Rule → Flow compiler (Workflow Rules were removed in #1398 and
workflowwas reclaimed for state machines), but theworkflow_ruleparadigm tag survived inActionParadigmSchemaand on every built-in node descriptor. There is no declarative Workflow-Rule authoring view to feed, so the tag is now retired:ActionParadigmSchemakeeps['flow', 'approval'], and thehttp/notify/connector_actiondescriptors (plus the deprecated-alias fallback) advertise['flow', 'approval']. Approval execution convergence is delivered by the ADR-0019 approval Flow node, not a compiler. ADR-0018's status and migration table are updated to mark M3 shipped, M4 framework-complete, and M5 dropped.
-
36719db: fix: AI-built apps are usable immediately — sync new object tables on publish + emit valid kanban config
Two gaps found by end-to-end testing of an AI-built app:
-
A freshly-published object couldn't accept records until a server restart. Publishing a drafted object registered it in the in-memory registry but never created its physical table (table sync only ran at boot), so inserts failed with
object_not_found("no such table"). AddedObjectQL.syncObjectSchema(name)(a targeted, idempotent single-object schema sync) and call it from the publish paths (protocol.publishMetaItemandsaveMetaItemmode:'publish', viaensureObjectStorage). Best-effort + non-fatal. New objects are now CRUD-able the moment they're published. -
AI-generated kanban views rendered as plain lists (and sometimes failed validation). The blueprint
viewBodyemittedlist.type:'kanban'with nokanbanconfig;KanbanConfigSchemarequiresgroupByFieldandcolumns. Added an optionalgroupByto the blueprint view schema (lenient + strict) and haveapply_blueprintsetlist.kanban = { groupByField, columns }— using the view's explicitgroupBywhen given, else inferring the object's firstselectfield. AI-built kanban views now validate, publish, and carry a real group-by field.
-
-
06f2bbb: fix(ai): make ADR-0033 blueprint authoring work with OpenAI structured outputs
Two bugs surfaced by a live end-to-end run (Studio chat → blueprint → draft → review → publish) against a real model (OpenAI via the Vercel AI Gateway) — both invisible to the existing unit tests:
-
propose_blueprintfailed against OpenAI strict structured outputs.SolutionBlueprintSchemauses optional fields and a free-formseedDatarecord; OpenAI's strict mode requires every property listed inrequiredand rejects openadditionalProperties, sogenerateObjecterrored ('required' … must include every key in properties) and the agent silently fell back to free-text. AddsSolutionBlueprintStrictSchema— a strict-compatible mirror (optional → nullable, noz.record) used only as thegenerateObjectoutput contract. The lenientSolutionBlueprintSchema(and every existing consumer/test) is unchanged; the blueprint tools strip thenulls the strict contract emits so downstream stays clean. -
Tool-only assistant turns failed to persist.
ai_messages.contentis required, but an assistant turn that only calls a tool has no text, so the insert failed, the turn was dropped, and the next turn lost context (the agent re-proposed instead of applying the confirmed blueprint).ObjectQLConversationService.addMessagenow synthesizes a readable placeholder from the tool names ((called propose_blueprint)) plus a defensive non-empty fallback.
With both fixes the full plan-first loop runs end-to-end on OpenAI models: propose → confirm → batch-draft objects/views/dashboards/app → review/diff → publish.
-
-
424ab26: fix(seed): reject object-wrapped relationship references and constrain them at compile time
Seed datasets resolve
lookup/master_detailreferences by matching the value against the target record's externalId — so the value must be the plain natural-key string (e.g.account: 'Acme Corp'), never a wrapper object likeaccount: { externalId: 'Acme Corp' }. The wrapper was silently skipped by the loader, fell through unresolved, and reached the SQL driver as a non-bindable value — masked on an always-empty:memory:DB but crashing on a persistent one with "SQLite3 can only bind numbers, strings, bigints, buffers, and null" once seeds re-ran as updates.defineDatasetnow constrains reference fields tostring | nullat compile time (derived from each field'stype), so the object form is a type error.SeedLoaderServicenow fails loudly with an actionable message (and drops the value instead of handing it to the driver) when a reference is an object — consistent behavior across all drivers, no longer silently masked.
-
b391955: feat(ai): blueprint app-building — propose/draft the navigation app, not just the data model
The plan-first blueprint (ADR-0033 §4) now also designs the app (the navigation shell end users open in the App Launcher), so "build me a project-management application" yields an openable app — not just its objects, views, and dashboards.
SolutionBlueprintSchema(@objectstack/spec/ai) gains an optionalapp: { name, label?, icon?, nav? }, where each nav entry targets a created object or dashboard.navmay be omitted to auto-surface every object (then dashboard).apply_blueprintexpands the app into anAppSchemabody (single-levelnavigationof object/dashboard items) and drafts it last — through the same draft-gated, per-type-validatedstageDraftpath as everything else. It never setsisDefault.propose_blueprintnow asks the agent to include the app and reportscounts.app.
Still draft-gated: nothing is live until the human publishes. Scope is basic app-building (one app, flat nav); areas/groups/mobile-nav remain author-it-later via
update_metadata. -
f06b64e: feat(ai): ADR-0033 Phase C — plan-first blueprint authoring
For high-level goals ("build me a project-management system") the metadata assistant now designs before it builds. Adds a
SolutionBlueprintSchema(@objectstack/spec/ai) describing proposed objects, fields, relationships, views, dashboards, and seed data with stated assumptions, plus two tools:propose_blueprint(goal)— emits a structured blueprint via structured output. Nothing is persisted; the agent presents it for conversational confirmation and asks at most 1–2 structure-deciding questions.apply_blueprint(blueprint)— only after the human approves, batch-drafts every artifact through the Phase A draft path (protocol.saveMetaItem({mode:'draft'})), validated per-type and partial-tolerant (a bad item is reported, the rest still draft). Seed data is reported as proposed, not auto-applied (no runtimedatasettype).
A new
solution_designskill carries the plan-first instructions and is bound tometadata_assistantalongsidemetadata_authoring. The shared draft-write primitive is exported from the metadata tools asstageDraftand reused, keeping one draft-write path. -
023bf93: fix(spec): reject unknown top-level keys on
ObjectSchema.create()(#1535)ObjectSchemaBaseis a plainz.object({...})(Zod default.strip()), so any unknown top-level key passed toObjectSchema.create()—workflows, a typo'dvalidation/indexs, etc. — was discarded silently: no error, no warning, and a greentsc. Declarative metadata an author believed they shipped (e.g. object-levelworkflows: [...]) vanished from every built artifact, dead from day one. This is the metadata-shape analogue of ADR-0032's "no silent failure" principle.create()now rejects unknown top-level keys with a precise, fixable build error that names the offending key(s), suggests the intended key on a likely typo (validation→validations), and — for known-confusable keys likeworkflows— points authors at the supported mechanism (a lifecycle hooksrc/objects/<name>.hook.tsor a top-levelrecord_changeflow; there is no object-levelworkflows[]field). The factory signature also constrains excess keys tonever, so the mistake is caught attsctime as well as at build.The non-strict
ObjectSchema.parse()load path (registry/artifact validation) is unchanged.Also fixes two platform objects (
sys_secret,sys_setting_audit) that carried silently-strippedviews/scope/defaultViewNamekeys: their intended list views are migrated to the supportedlistViewsfield (type: 'list'→'grid') so they now render instead of being dropped. Theobjectstack-dataskill's CRM blueprint no longer teaches the non-existentworkflows[]shape.
-
955d4c8: ADR-0018 M3: unified
http/notifyexecutors backed by a generic HTTP outbox.Promotes a reliable outbound-HTTP delivery outbox into
service-messaging(the raw-callout counterpart to the notification outbox) and routes the Flowhttpnode through it — closing the "http_requestis a barefetch()with no retry" gap. The five divergent outbound verbs collapse onto canonicalhttp/notify.@objectstack/service-messaging(additive):IHttpOutbox/HttpDeliverygeneric raw-callout shape (source/refId/dedupKey/label/signingSecret),SqlHttpOutboxover a newsys_http_deliveryobject,MemoryHttpOutbox,HttpDispatcher(per-partition cluster lock, claim/ack/retry/dead-letter), and a sharedsendOnce+ 7-step jittered retry schedule.MessagingServicegainssetHttpOutbox()/isHttpDeliveryReady()/enqueueHttp(); the plugin wires the outbox + dispatcher atkernel:ready.
@objectstack/service-automation:- Canonical
httpexecutor —durable: trueenqueues onto the messaging HTTP outbox (retry/dead-letter); otherwise an inlinefetch()preservinghttp_request's request/response semantics. engine.registerNodeAlias()— registers a delegating executor + adeprecated/aliasOfdescriptor.http_request/http_call/webhookare now deprecated aliases ofhttp; existing flows keep running.notifydescriptor markedneedsOutbox(its delivery is outbox-backed).
@objectstack/spec:flow.zodaddshttpto the builtin node-type seed set.plugin-webhookscut-over to the shared outbox is a deliberate follow-up. -
b046ec2: feat(automation): BPMN ⇄ structured-construct model mapping (ADR-0031, task 5)
Add the semantic bridge between the structured control-flow constructs (the native model) and the BPMN gateway/boundary/multi-instance vocabulary (kept for interop only), at the flow-model level — independent of any wire format (
automation/bpmn-mapping.ts):exportConstructsToBpmn(flow)expands each construct into its BPMN interchange shape —parallel→parallel_gateway(AND-split) + branch regions +join_gateway(AND-join);try_catch→ the protected activity + an errorboundary_event+ the handler region;loop→ its body marked with multi-instance loop characteristics — so external BPM tools see a well-formed BPMN graph. Each expansion's anchor carries anosConstructextension marker.importBpmnToConstructs(flow)folds that BPMN shape back into the constructs: exact reconstruction from theosConstructmarker (soconstruct → BPMN → constructis identity), and a best-effort structural fold of foreignparallel_gateway/join_gatewaypairs, with diagnostics for shapes it can't safely fold.
BPMN 2.0 XML (de)serialization layers on top of this mapping and remains a plugin concern (per
bpmn-interop.zod.ts), out of scope here. -
2170ad9: client SDK: add
approvalsnamespace; remove dead workflow approve/reject surface (ADR-0019)ADR-0019 collapsed approval into Flow: approval is no longer a workflow step but a first-class flow node that opens a request and suspends the run, with a human decision resuming the flow down the matching
approve/rejectedge. The server already exposes this as a dedicated/api/v1/approvalssurface (registerApprovalsEndpoints), but the client SDK still carried the old approval-on-workflowmethods, which pointed at routes that never existed.-
@objectstack/clientgains aclient.approvalsnamespace backed by the real REST surface:listRequests(filter?)→GET /approvals/requests(the "my approvals" inbox; filter bystatus(single or array),object,recordId,approverId,submitterId).getRequest(id)→GET /approvals/requests/:id.approve(id, { actorId?, comment? })/reject(id, …)→POST /approvals/requests/:id/{approve,reject}(records a decision and resumes the owning flow run).listActions(id)→GET /approvals/requests/:id/actions(audit trail).
The approval runtime types (
ApprovalRequestRow,ApprovalActionRow,ApprovalStatus,ApprovalDecisionInput,ApprovalDecisionResult) are re-exported so consumers can type the namespace without reaching into@objectstack/spec. -
Removed the dead workflow approve/reject surface.
client.workflow.approve/client.workflow.rejectand the backingWorkflowApprove*/WorkflowReject*protocol schemas, types,IProtocolServicemethods, and the/approve//rejectentries inDEFAULT_WORKFLOW_ROUTESare gone — approval decisions are no longer recorded on a workflow record.workflowis reclaimed for state machines, sogetConfig/getState/transitionare unchanged. -
Discovery advertises the new route key:
ApiRoutesSchema.approvals.
-
-
7648242: Enforce every declared validation-rule type on the write path; trim the three that can't be (#1475).
The
validationsunion advertised nine rule types but only three (state_machine,cross_field,script) ran on insert/update — the other six were accepted by the schema yet silently did nothing. This closes that gap on both sides: implement the synchronous types, and trim the ones that don't belong in a write-path rule.@objectstack/objectql(additive): the rule evaluator now enforces three more types, all deterministic, synchronous, side-effect-free predicates over one record:format— a field value against aregexand/or a named format (email/url/phone/json). Runs only when the write touches the field and the value is non-empty; a malformed regex fails open.json_schema— a JSON field validated against a JSON Schema viaajv(compiled result memoised per schema). Accepts a parsed object or a JSON string; an unparseable string is itself a violation; an uncompilable schema fails open.conditional— evaluateswhen, then recurses intothen/otherwise. The nested rule supplies the message; the outer conditional'sseveritydecides blocking.needsPriorRecordnow recurses into conditional branches.
Adds
ajvas a dependency and three error codes (invalid_format,invalid_json,json_schema_violation).@objectstack/spec(breaking for unused declarations): removes theunique,async, andcustomvalidation-rule variants (and theUniquenessValidationSchema/AsyncValidationSchema/CustomValidatorSchemaexports). They were never enforced and each needs I/O or a handler model a write-path rule must not carry. Use the layer that already does each correctly: uniqueness → a unique index (ObjectSchema.indexes,partialfor scope) or field-levelunique: true; async/remote → the client form layer; custom code → abeforeInsert/beforeUpdatelifecycle hook. Field-levelunique: trueis unaffected.examples/app-showcasedemonstrates and verifies each newly-enforced type. See the ADR-0020 addendum for the rationale. -
60f9c45: feat(automation): structured control-flow constructs (ADR-0031) — loop container
Adopt structured control-flow as the native, AI-authored flow model (ADR-0031), choosing representation (B) nested sub-structure: containers carry their body as a self-contained single-entry/single-exit region in
config.- spec: new
automation/control-flow.zod.tsdefining theloopcontainer (config.body),parallelblock (config.branches[], implicit join), andtry/catch/retry(config.try/config.catch/config.retry) configs, plus region well-formedness analysis (analyzeRegion,findRegionEntry) andvalidateControlFlow(single-entry/single-exit, acyclic; bounded loop). - engine:
registerFlow()now rejects malformed control-flow regions before a flow can run; newAutomationEngine.runRegion()executes a body region in the enclosing variable scope without touching the shared DAG traversal. - loop executor: replaces the no-op
loopstub with a real iteration container — binds the iterator/index variables and runs the body once per item under a hard max-iteration guard. Legacy flat-graph loops (noconfig.body) keep working — the construct is additive.
Parallel-block and try/catch engine execution and BPMN interop mapping remain follow-ups (issue #1479, tasks 3–5).
- spec: new
-
c4a4cbd: ADR-0032 (phase 1): validate-by-default expression layer — no silent failure.
Kills the #1491 class where a malformed predicate (e.g. the
{record.x}template-brace-in-CEL mistake) silently evaluated tofalseand made a flow "fire" with no effect:- service-automation: flow
evaluateConditionno longer swallows CEL failures tofalse— it throws an attributed, corrective error; andregisterFlownow parse-validates every predicate (start/decision/edge condition) at registration, failing loudly with the offending location + source + the fix. - formula: new shared validator —
validateExpression(role, src, schema?),introspectScope,CEL_STDLIB_FUNCTIONS— with schema-aware field-existence- did-you-mean. The
{{ }}template engine gains a formatter whitelist (currency/number/percent/date/datetime/truncate/upper/lower/default/…) with defined value→string semantics; arbitrary logic in holes is rejected. Plain{{ path }}stays back-compatible.
- did-you-mean. The
- cli:
objectstack compilevalidates every flow / validation-rule / field-formula predicate against the resolved object schema and fails the build with located, corrective messages. - service-ai: new agent-callable
validate_expressiontool so authoring agents self-correct before committing. - spec: fix the
FlowSchemaJSDoc example that taught the badcondition: "{amount} < 500"single-brace form.
- service-automation: flow
-
02d6359: docs(automation): document ADR-0031 control-flow constructs; fix dangling reference card
- guide:
content/docs/guides/metadata/flow.mdxnow documents the structured control-flow constructs — theloopcontainer,parallelblock (implicit join), andtry_catch(try/catch/retry) — with config examples and the region/DAG model. The Node Types table is updated accordingly. - doc generator:
build-docs.tsnow cards only reference pages that were actually generated. Control-flow's schemas embed CEL-expression transforms (likeFlow/FlowEdge) and so have no JSON-Schema page; the index previously carded every.zod.ts, producing a dangling "Control Flow" 404 link. Cards now align withmeta.json(generated pages only).
- guide:
-
8fa1e7f: Fix the docs generator (
build-docs.ts) leaking an unmatched</{into generated MDX, which broke theapps/docsTurbopack build (e.g. a SemVer range">=4.0 <5"in a.describe()string was read as the start of a JSX tag). Unmatched openers are now emitted as HTML entities (</{); union-variant descriptions also go through the escaper. -
55866f5: Fail loud instead of silently minting an ephemeral encryption key; ship a persistent env-master-key provider as the default (#1507).
The default
ICryptoProviderbacks every secret-at-rest in the platform — encrypted settings (sys_setting.value_enc), ObjectQLsecretfields, and runtime datasource credentials. Its key resolution previously fell back, silently, to a fresh per-processrandomBytes(32)key (or auto-minted a new on-disk key on every boot) when no stable key was available. In an ephemeral-FS container or a multi-node cluster, each restart / each node then encrypts under a different key, and every previously-writtensys_secretvalue becomes undecryptable. The failure was invisible at encrypt and boot time and only surfaced later as "all my saved passwords / API keys / DB credentials fail to decrypt".- Renamed
InMemoryCryptoProvider→LocalCryptoProvider. The old name implied an ephemeral key when the provider in fact persists one.InMemoryCryptoProviderstays as a deprecated alias for backward compatibility. - Added
OS_SECRET_KEYas the canonical production master key (32-byte hex or base64), the documented production default.OS_DEV_CRYPTO_KEYremains the dev convenience key. - Fail-loud in production. When
NODE_ENV=productionand no stable key source (env var or a pre-existing persisted file) is available, the provider now throws an actionable error at construction instead of generating a key — turning silent data-loss into a config error at boot. It never auto-mints a key in production. Development and test keep the ergonomic fallback (persisted dev key / ephemeral test key). servesurfaces the production-key error verbatim and refuses to wire an unstable provider forsecretfields.
KMS / Vault providers (managed custody, per-tenant keys, automatic rotation) remain future/enterprise plug-ins behind the same
ICryptoProviderseam; "your stored secret is still there after a reboot" stays open-source. - Renamed
-
23c7107: ADR-0020 — converge the three "state machine" declaration shapes to one enforced
state_machinevalidation rule.Before this change a record state machine could be declared three ways (a
workflowmetadata type, anobject.stateMachinesmap, or astate_machinevalidation rule) and none of them were enforced at runtime — a declarative guardrail that was pure decoration, and a hallucination trap for AI authors.Enforcement (
@objectstack/objectql)- New
validation/rule-validator.tsevaluates the object'svalidationsunion on the write path:evaluateValidationRules,needsPriorRecord, and thelegalNextStatesintrospection helper (all exported from the package root). state_machinerules reject illegalfieldtransitions on update (with the rule'smessage);script/cross_fieldpredicate rules now also fire (they were silently broken on PATCH updates because only the patch, not the prior record, was available). The engine plumbs the prior record into rule evaluation on single-row update; multi-row (updateMany) updates log a warning and skip rule evaluation rather than enforce on incomplete data.
Convergence / retirement (
@objectstack/spec) — breaking- Retires the
workflowmetadata type (removed from the metadata-type enum, the registry, the schema map, theworkflowscollection key, and the plural→singular mapping). - Removes the
object.stateMachinesmap and thestack.workflowsarray. Thestate_machinevalidation rule is the single canonical home. - The XState-style
StateMachineSchemafile is kept (still used by the agent conversation lifecycle and the discovery protocol); only its role as theworkflowmetadata-type backing schema was removed. The optionalworkflowRPC service surface (CoreServiceName.workflow,/api/v1/workflow,IWorkflowService) is kept as a documented follow-up.
Introspection (
@objectstack/runtime)- Adds
GET /metadata/objects/:name/state/:field?from=:state, returning the legal next states for a field (next: nullwhen no FSM governs the field,[]for a declared dead-end) so UIs/agents read the transition table instead of re-deriving it.
Surfaces (
@objectstack/platform-objects,@objectstack/cli)- Studio drops the standalone "Workflow Rules" nav (state machines are edited alongside the object's other validation rules).
explainno longer listsworkflowas a related metadata type.
Migration: replace a
workflow/StateMachineConfigdeclaration with astate_machinevalidation rule on the object (field+{ from: [allowedTo] }transition table), and move any side-effecting actions (emails, task creation) into a record-triggered or scheduled Flow (ADR-0019). See the migratedexamples/app-crmflows for the pattern. - New
-
c72daad: ADR-0029 D7 — Setup app navigation contributions.
Adds the UI-layer analog of object
own/extend: a package can contribute navigation items into an app it does not own, so a shared admin app can be a thin shell while each capability plugin ships the menu for the objects it owns.@objectstack/spec— newNavigationContributionSchema({ app, group?, priority, items }) and an optionalnavigationContributionsfield on the manifest.@objectstack/objectql—SchemaRegistry.registerAppNavContribution()plus lazy merge ingetApp/getAllApps(by target group id + priority, cloning so the stored app is never mutated); the engine wiresmanifest.navigationContributionsduring app registration.@objectstack/platform-objects— the Setup app becomes a shell of empty group anchors; its entries for platform-objects-owned objects move toSETUP_NAV_CONTRIBUTIONS.@objectstack/plugin-auth— registersSETUP_NAV_CONTRIBUTIONSalongside the Setup app it already registers.@objectstack/plugin-webhooks— contributes itsWebhooks/Webhook Deliveriesentries into the Setupgroup_integrationsslot (it ownssys_webhook/sys_webhook_deliveryper K2.a), demonstrating end-to-end cross-plugin contribution.
The rendered Setup nav is identical to the former static artifact — just assembled from its owners. A disabled/absent capability contributes nothing and its slot stays empty (in addition to the existing
requiresObjectgating). This unblocks moving each remaining K2 domain's menu out of the monolith with its objects. -
f115182: ADR-0019 — App as the consumer-facing unit. The consumer Marketplace surfaces exactly one user-visible noun, the App.
- Adds
CONSUMER_INSTALLABLE_TYPESandisConsumerInstallable(type)(the single source of truth for "what a consumer can install"). - Constrains
MarketplaceListingSchema.packageTypetoCONSUMER_INSTALLABLE_TYPES(defaultapp) so a non-App (driver/server/plugin/…) listing cannot be represented — the "consumers see only Apps" guarantee is enforced in the data contract, not a forgettable query filter. defineStack()now enforces at most one App per package: a package withmanifest.type === 'app'may not define more than one app — the banned "suite contains apps" shape throws with a clear fix (fold into one app with multiple tabs, or split into separate packages). Zero apps is allowed; non-apppackage types are unconstrained. Non-breaking for existing stacks.
The package
typeenum is unchanged; the additions are non-breaking. No runtime/registry/execution changes. - Adds
-
2faf9f2: External Datasource Federation (ADR-0015) — Phase 1.
Adds the spec foundation and the DDL gate for federating mature external databases without ObjectStack ever mutating their schema:
Datasource.schemaMode(managed|external|validate-only) andDatasource.externalsettings, with a cross-field invariant.Object.externalbinding (remote table/schema, writability, column map).- Shared error contract:
ExternalSchemaMismatchError,ExternalWriteForbiddenError,ExternalSchemaModeViolationError(stablecodes) + structuredSchemaDiffEntryrendering. driver-sqlDDL gate: schema-mutating DDL (initObjects/syncSchema/dropTable) is rejected whenschemaMode !== 'managed'.
All changes are additive and backward-compatible (
schemaModedefaults to'managed'). -
2faf9f2: External Datasource Federation (ADR-0015) — Phase 2 (service core).
Adds the federation service contract, the type-compatibility matrix, and a new service package that introspects, drafts, and validates federated objects:
@objectstack/spec:data/type-compat.ts— dialect-aware SQL↔field-type matrix (canonicalizeSqlType,suggestFieldType,isCompatible) for postgres/mysql/sqlite/snowflake/bigquery/mongo.contracts/external-datasource-service.ts—IExternalDatasourceServiceplusRemoteTable,GenerateDraftOpts,ObjectDraft,SchemaValidationResult/Report.
@objectstack/service-external-datasource(new): implements the service —listRemoteTables,generateObjectDraft(renders a reviewable*.object.tswith// REVIEW:markers),validateObject/validateAll(structuredSchemaDiffEntrydiffs), andrefreshCatalog. Decoupled from the kernel via injected I/O; kernel plugin registers it as theexternal-datasourceservice.
REST routes and the
os datasourceCLI commands follow in a subsequent slice. -
2faf9f2: External Datasource Federation (ADR-0015) — Phase 3 spec:
external_catalogmetadata type.- Registers
external_cataloginMetadataTypeSchemaandDEFAULT_METADATA_TYPE_REGISTRY(system domain,allowRuntimeCreate: true, not org-overridable). - Adds
data/external-catalog.zod.ts—ExternalCatalogSchema/ExternalTableSchema/ExternalColumnSchemafor persisting a cached remote-schema snapshot of a federated datasource (consumed byrefreshCatalog, the boot-validation gate, and Studio's schema browser).
- Registers
-
ff3d006: Screen-flow runtime — interactive
screennodes (suspend → render → resume).A
screennode that declares input fields now suspends the run on entry (reusing the ADR-0019 durable pause), surfaces aScreenSpecdescribing the form, and resumes with the collected values applied as bare flow variables so downstream nodes read them via{var}. (waitForInput: falseforces the old server pass-through.)- spec:
AutomationResult.screen?: ScreenSpec,ResumeSignal.variables?(bare vars),IAutomationService.getSuspendedScreen?(runId). - service-automation: the
screenexecutor builds theScreenSpecand suspends when fields are present; the suspend/resume plumbing threads the screen throughFlowSuspendSignal→SuspendedRun→ the paused result;resume()setssignal.variablesas bare flow variables;getSuspendedScreen. - runtime:
POST /api/v1/automation/:name/runs/:runId/resume(body{ inputs }) andGET …/runs/:runId/screen, wired through both the dispatcher route table andhandleAutomation.
Verified end-to-end headlessly: the showcase Reassign Wizard launches → pauses at the "New Assignee" screen → resumes with the input → the task is reassigned. The objectui
FlowRunnerUI that renders these screens ships separately. - spec:
-
5e831de: Seed data: first-class identity binding + loud failures (fixes #1389)
Records seeded via
defineDataset/defineStack({ data })can now bind to a platform user withcel\os.user.id`(and to the org withcel`os.org.id``), which previously never resolved at boot.os.user/os.orgnow actually resolve. The runtime provisions a deterministic, non-loginable system user (usr_system, rolesystem) before any seed runs and binds it toos.user, so identity-derived seed values resolve even on a fresh boot — before the first human sign-up. The human login admin remains a separate better-auth identity and need not own seed data. Exposed as the canonicalSystemUserId.SYSTEMconstant.- New
SeedLoaderConfig.identitycarries theos.user/os.orgsubject into CEL evaluation (@objectstack/spec). - Failures are loud, not silent. A record whose CEL value can't resolve
(e.g. a required
cel\os.user.id`` with no identity) — or that fails to write — is now counted as an error, marks the load unsuccessful, and logs an actionable message, instead of being silently dropped.
-
58b450b: Make metadata labels follow the active UI language without a page refresh (#1319).
The client now carries the active locale on every request (
Accept-Language,setLocale/getLocale), the protocol ETag is locale-aware so cached metadata no longer collides across languages, and theclient-reactmetadata hooks refetch when the locale changes. Theapps/accountconsole wires its router locale through so a language switch relabels server-resolved object/field/view labels in place instead of leaving the UI half-translated until reload. -
82eb6cf: Fix system-metadata translations: locale fallback, app/dashboard localization, and coverage gaps.
Switching the UI language left many surfaces in English. Three root causes are addressed:
-
Locale fallback (server). The metadata translation resolver (
@objectstack/speci18n-resolver) now resolves a requested locale against the locales actually present in the bundle (exact → case-insensitive → base-language → variant), so a request forzhcorrectly hits thezh-CNbundle instead of falling back to English. This mirrorsresolveLocalein@objectstack/coreand benefits every resolver (objects, views, actions, settings, metadata forms). -
App & dashboard localization (server). Added
translateAppandtranslateDashboardresolvers and wiredapp/dashboardinto the REST/metatranslation path. App labels, sidebar/navigation group labels, and dashboard titles/widgets were previously never localized at the API boundary even though the translation data existed. -
Coverage & quality (data). Added translations for the previously untranslated platform objects
sys_share_link,sys_view_definition, andsys_metadata_audit(and registered them in the i18n-extract config so future extractions keep them). Replaced English placeholder strings left in thezh-CN/ja-JP/es-ESobject and metadata-form bundles (notably actionconfirmText/successMessageprompts). Added the missinges-ESbuilt-in Settings bundle in@objectstack/service-settings.
-
-
13d8653: Record-change flow trigger — auto-launch flows on data mutations.
Completes the automation engine's
FlowTriggerextension point so flows whosestartnode declares a record-change trigger (config: { objectName, triggerType: 'record-after-update', condition }) actually fire on the matching mutation. Previously the slot was dead — nothing calledtrigger.start— so such flows could only run via a manualengine.execute().Engine baseline (
@objectstack/service-automation)- Redefines
FlowTriggeraround a parsedFlowTriggerBinding(flowName, object, event, condition, schedule, raw config). The engine parses the start node and hands the trigger a normalized binding, keeping trigger plugins decoupled from flow-definition internals (mirrorsconnector_action↔connector-rest). - Ordering-independent, bidirectional wiring:
registerFlow/toggleFlowactivate bindings;registerTriggerretro-binds already-registered flows (a trigger plugin wires up onkernel:ready, after flows are pulled in);unregisterFlow/unregisterTrigger/disable tear them down. - Centralized start-condition gate in
execute(): the start node'scondition(e.g.status == 'done' && previous.status != 'done') is evaluated once for every trigger type and manual runs; false ⇒{ skipped: true }. - Seeds
record, flattened record fields, andpreviousinto flow variables. - New
getActiveTriggerBindings()getter + exportsFlowTriggerBinding.
Spec (
@objectstack/spec)- Adds
previous?toAutomationContext— the pre-update "old" row, so flows can gate on transitions.
New package (
@objectstack/plugin-trigger-record-change)- The concrete trigger: subscribes to ObjectQL lifecycle hooks
(
record-after-update→afterUpdate, etc.), builds anAutomationContextfrom the new/old record, and runs the flow. Error-isolated (a flow failure never breaks the CRUD write); graceful degrade when the automation service or ObjectQL engine is absent (mirrorsplugin-audit).
The
scheduletrigger (ticker/cron +sys_joblifecycle) is a follow-up. - Redefines
-
5e7c554: Rename kernel plugin-sandbox permission schemas to remove a naming footgun (issue #1383).
@objectstack/spec/kernelexportedPermissionSchema/PermissionSetSchema(and thePermission/PermissionSettypes) for the plugin-sandbox security model. Their names collided with the metadata-protocol permission set exported from@objectstack/spec/security(PermissionSetSchema), making it very easy to validate thepermission/profilemetadata type against the wrong schema and reject every legal payload.The kernel symbols are now prefixed with
Pluginto reflect their specialized semantics:Old ( @objectstack/spec/kernel)New PermissionSchemaPluginPermissionSchemaPermissionSetSchemaPluginPermissionSetSchemaPermission(type)PluginPermissionPermissionSet(type)PluginPermissionSetThe metadata
permission/profiletypes are unchanged — keep usingPermissionSetSchemafrom@objectstack/spec/security.
-
47a92f4: Promote
email_templateto a first-class metadata type using the canonicalEmailTemplateDefinitionSchema.Previously
email_templatehad two competing Zod schemas (Prime Directive #8 violation): the legacyEmailTemplateSchema(a sub-shape ofNotification) and the richerEmailTemplateDefinitionSchema. The runtime metadata protocol (packages/objectql/src/protocol.ts) and Studio's property panel registered the legacy one, which is why all the new fields (name,label,category,locale,bodyHtml,bodyText, …) were reported as “declared in form layout but missing from schema”.This change:
- Repoints the
email_templateentry inTYPE_TO_SCHEMA(packages/objectql/src/protocol.ts) and inBUILTIN_METADATA_TYPE_SCHEMAS(packages/spec/src/kernel/metadata-type-schemas.ts) toEmailTemplateDefinitionSchema. The legacyEmailTemplateSchemais kept only as an inline sub-shape insideNotification. - Adds an
emailTemplatescollection todefineStack()input (packages/spec/src/stack.zod.ts), registers it inMAP_SUPPORTED_FIELDS/PLURAL_TO_SINGULAR(packages/spec/src/shared/metadata-collection.zod.ts), wires it intoARTIFACT_FIELD_TO_TYPE(packages/metadata/src/plugin.ts) andAPP_CATEGORY_KEYS(packages/runtime/src/app-plugin.ts). - Rewrites
packages/spec/src/system/email-template.form.tsfor the new schema with sections for Identity, Subject, HTML body, Plain-text body, Variables, Delivery overrides, Status. - Ships three reference templates in
examples/app-crm/src/emails/:crm.deal_won(rewritten to canonical shape),crm.welcome(new),crm.lead_followup(new), and wires them into the CRM stack viaemailTemplates: Object.values(emails).
End-to-end verified in Studio: list view at
/_console/apps/studio/metadata/email_templateshows all three entries; the detail view renders the EmailTemplatePreview iframe and the property panel cleanly renders every canonical field (no missing-schema warnings).GET /api/v1/metanow returns the newpropertiesset (name, label, category, locale, subject, bodyHtml, bodyText, variables, fromOverride, replyTo, active, isSystem, description). - Repoints the
-
dc72172: Breaking: Removed
@objectstack/driver-tursoand@objectstack/knowledge-tursofrom the open-core framework.The Turso/libSQL driver and its native-vector knowledge adapter now ship exclusively with the ObjectStack Cloud distribution (
objectstack-ai/cloud). Rationale: Turso is used only for cloud/edge multi-tenant deployments — local development uses better-sqlite3 (faster), and the Turso integration is part of ObjectStack's commercial offering.@objectstack/driver-turso→objectstack-ai/cloud/packages/driver-turso@objectstack/knowledge-turso→objectstack-ai/cloud/packages/knowledge-tursoITursoPlatformServicecontract (spec/contracts/turso-platform.ts) — removed entirelyTursoConfigSchema,TursoDriverSpec,TursoMultiTenantConfigSchema,TenantResolverStrategySchema, etc. — moved into@objectstack/driver-turso(re-exported from cloud)
packages/runtime/src/standalone-stack.ts:databaseDriverenum no longer accepts'turso';libsql:///https://URL detection removed. Cloud builds register the Turso driver via their own stack composition.packages/runtime/src/cloud/artifact-environment-registry.ts: droppedcase 'libsql'/'turso'. Cloud has its ownArtifactEnvironmentRegistrythat handles Turso.packages/cli/src/commands/serve.ts: removeddriverType === 'turso' | 'libsql'branch.packages/runtime/package.json,packages/cli/package.json: removed optional peerDep on@objectstack/driver-turso.packages/runtime/tsup.config.ts: removed@objectstack/driver-tursofromexternal.packages/spec/src/contracts/index.ts: stopped re-exportingturso-platform.js.packages/spec/src/data/index.ts: stopped re-exportingdriver/turso-multi-tenant.zod.
If you used
libsql://URLs or@objectstack/driver-tursodirectly, either:- Switch to
file:URLs (better-sqlite3 via@objectstack/driver-sql) for local/self-hosted deployments, or - Use ObjectStack Cloud, which ships the Turso driver as part of the commercial distribution.
-
74470ad: New
accountApp for self-service identity management +App.hiddenshell hintAdds a dedicated Account App (
name: 'account', iconuser-circle) that exposes the three end-user identity surfaces:- Two-Factor Authentication —
sys_two_factor - Linked Accounts —
sys_account - OAuth Applications —
sys_oauth_application
The app declares no
requiredPermissions, so every authenticated user can reach it — unlike Setup, which requiressetup.accessand therefore excludes the defaultmember_defaultpermission set. Combined with the C-tierresultDialogactions already shipped on these objects (2FA QR + backup codes, OAuthclient_secretreveal,link_socialredirect), this replaces the legacy standaloneapps/accountSPA with a single console + metadata-driven surface.New
App.hidden: booleanfield (packages/spec/src/ui/app.zod.ts) hides an app from the top-level App Switcher. Hidden apps stay fully routable and permission-checked; the shell is expected to surface them through the avatar / user dropdown instead. Mirrors the GitHub Settings / Google account chip / Salesforce Personal Settings pattern. The Account app is the first user.Wiring:
plugin-authregistersACCOUNT_APPalongsideSETUP_APP/STUDIO_APP(packages/plugins/plugin-auth/src/auth-plugin.ts). The legacy duplicate entries inside Setup's Advanced group are kept unchanged — they remain admin-only for tenant-wide inspection.Follow-up for objectui: the shell's
AppSwitcherand avatarDropdownMenuneed updating to honourapp.hidden(filter hidden apps out of the switcher; render them as dropdown menu entries). Tracked separately. - Two-Factor Authentication —
-
d29617e: Add
Action.resultDialogfor one-shot reveal of API responsesSome platform operations return values the user MUST copy now because they cannot be retrieved later — TOTP enrollment URIs, OAuth client secrets, backup recovery codes. Previously these were handled by bespoke account-app pages because actions only surfaced a
successMessagetoast.This change adds:
-
Action.resultDialog— describes a post-success modal that renders selected fields fromresult.data. Supportsqrcode,code-list,secret,text, andjsonfield formats. When set, renderers SHOULD suppresssuccessMessageand require explicit acknowledgement. -
Action.targetinterpolation contract — formalised TSDoc spelling out the${param.X}and${ctx.X}substitution rules (with mandatoryencodeURIComponentfor URL query positions). Used by redirect-style actions likelink_social.
New / updated platform actions:
sys_two_factor:enable_two_factornow reveals TOTP URI + backup codes; addedregenerate_backup_codes.sys_oauth_application:rotate_client_secretnow reveals the new secret; addedcreate_oauth_applicationtoolbar action.sys_account: addedlink_socialtoolbar action (type:url, templated target) for self-service identity linking.
These let the Setup app cover OAuth-app registration, 2FA enrollment, and social-account linking entirely through metadata, removing the last must-have reasons to ship a separate
apps/accountSPA.Renderer-side work (separate PR in
objectui): consumeresultDialog, implement${param}/${ctx}interpolation, shipResultDialogcomponent. Seec-tier-renderer-contract.mddesign note. -
-
c8b9f57: Metadata Admin engine — protocol foundations.
This is the backend half of the unified Metadata Admin shipped in the Setup app. The framework now exposes everything the engine needs to render a directory tile, schema-driven form, layered diff, references graph, and destructive-change confirmation for every registered metadata type.
GET /api/v1/meta/typesis now type-rich. Each entry includes{ icon, domain, schema (JSONSchema), allowOrgOverride, allowRuntimeCreate, supportsOverlay, ui? }so the client can render without a second round-trip per type.GET /api/v1/meta/:type/:name/referencesscans every registered metadata type for pointers to the given item (object fields, view sources, flow targets, permission objects, …) and returns the inbound edges so the UI can warn before deletes.GET /api/v1/meta/:type/:name?layers=code,overlay,effectivereturns each layer separately rather than the merged effective document, powering the 3-state diff editor (code source / overlay / effective).- Destructive-change detection on
PUT /api/v1/meta/object/:nameandPUT /api/v1/meta/field/:name: rejects field type narrowing, required toggled on without a default, removed enum values, etc., unless the client opts in withforce=true. - Env-var registry patch:
OBJECTSTACK_METADATA_WRITABLE=object,field,permission,view,…flipsallowOrgOverrideon for the listed types at boot, enabling runtime overlays for production without re-deploying spec. - New guide: Adding a Metadata Type walks through registry entry + Zod schema + optional custom editor.
Setup app navigation now uses the new component-route variant (
{ type: 'component', componentRef: 'metadata:directory' }) — the temporary/dev/metaroute is removed.
-
6e88f77: Auto-persist chat history when a
conversationIdis supplied.AIService.chatWithToolsandstreamChatWithToolsnow write the inbound user turn, each intermediate assistant/tool round, and the final assistant turn toai_messageswhenevertoolExecutionContext.conversationIdis set. Persistence is best-effort: failures are warned and never break the chat response.- Add
IAIConversationService.update(conversationId, { title?, metadata? })and a matchingPATCH /api/v1/ai/conversations/:idroute so clients can rename conversations and edit metadata. ObjectQLConversationServiceandInMemoryConversationServiceboth implement the newupdatemethod.
-
430067b: Introduce
IEmbedderprotocol and extract@objectstack/embedder-openaiplugin.What's new
IEmbeddercontract (@objectstack/spec/contracts/embedder.ts) — protocol-level interface for text → vector providers. One contract covers cloud APIs (OpenAI / 阿里通义 / 智谱 / 硅基流动 / 火山 Doubao / MiniMax), local Ollama daemons, and in-process embedders.@objectstack/embedder-openai— new package. Drop-in for any OpenAI-shape endpoint viabaseUrl. Ships preset constants for 8 mainstream providers (createOpenAIEmbedder({ preset: 'siliconflow', ... })) and pre-baked dimensions for 16+ popular models.
Breaking changes (
@objectstack/knowledge-turso)OpenAIEmbeddingProvideris removed — install@objectstack/embedder-openaiand useOpenAIEmbedderinstead (identical option shape).EmbeddingProvidertype alias kept as a deprecated re-export ofIEmbedderfor smoother migration; will be removed in a future major.HashEmbeddingProvideris now an alias for the renamedHashEmbedderclass — no functional change.
Migration
- import { OpenAIEmbeddingProvider } from '@objectstack/knowledge-turso'; + import { OpenAIEmbedder } from '@objectstack/embedder-openai'; - const embedding = new OpenAIEmbeddingProvider({ apiKey }); + const embedding = new OpenAIEmbedder({ apiKey });
For 国内 providers, use presets:
import { createOpenAIEmbedder } from "@objectstack/embedder-openai"; const embedding = createOpenAIEmbedder({ preset: "siliconflow", // or 'dashscope', 'zhipu', 'doubao', 'ollama', … apiKey: process.env.SILICONFLOW_API_KEY!, model: "BAAI/bge-m3", });
-
4f9e9d4: Settings → runtime bridge:
embedder_*settings now build a realIEmbedderand register it as a kernel-level DI service.@objectstack/spec- Exports
EMBEDDER_SERVICE = 'embedder'fromcontracts/embedder.tsas the canonical DI token for the kernel-registered embedder.
@objectstack/service-ai- Adds
@objectstack/embedder-openaias an optional peer dependency (matches the@ai-sdk/*provider plugins pattern). AIServicePlugin.bindSettings()now also:- Reads
embedder_provider/embedder_api_key/embedder_model/embedder_base_url/embedder_dimensionsfrom theainamespace. - Dynamically imports
@objectstack/embedder-openaiand constructs anOpenAIEmbedderviacreateOpenAIEmbedder({ preset, … }). - Registers / replaces the instance under
EMBEDDER_SERVICE. When the operator setsembedder_provider = none, the service is left unset so adapters can fail fast with a clear message. - Subscribes to
settings:changedfor theainamespace so embedder swaps go live without restart (mirrors the chat-adapter pattern). - Overrides the manifest's fallback
ai/test_embedderaction with a live one-shotembed(['ping'])round-trip against the form's (possibly unsaved) values. Reports vector dims + latency.
- Reads
@objectstack/knowledge-tursoKnowledgeTursoPlugin'sembeddingconstructor option is now optional. When omitted, the plugin resolvesEMBEDDER_SERVICEfrom the kernel atstart()time — typically the embedder built by@objectstack/service-aifrom theaisettings namespace.- Explicit
embeddingstill wins when both are present (useful for tests and multi-embedder setups). - Logs
(embedder=<id>, dims=<n>)on adapter registration so operators can confirm wiring at a glance. - When neither path resolves, the plugin warns with a one-line hint
pointing to
Settings → AI & Embedderand no-ops gracefully (the host kernel still boots).
Tests
service-ai: +5 cases (now 85) coveringai/test_embedderaction registration,provider=nonewarning, missing-api-key error, custom-provider-without-base-URL error, and the full happy path (mocked fetch → embedder registered underEMBEDDER_SERVICE→ test_embedder action returns vector dims).knowledge-turso: newplugin.test.ts(+5 cases) covering deferred construction, EMBEDDER_SERVICE fallback, explicit-wins precedence, missing-both warn-and-noop, and missing-knowledge-service warn.
End-to-end now possible: operator opens Settings → AI & Embedder, picks 硅基流动 + paste API key + chooses
BAAI/bge-m3, hits Save. Within the same process,EMBEDDER_SERVICEis registered/replaced,KnowledgeTursoPlugin(if started without an explicit embedder) picks it up, and subsequentknowledge.search()calls embed via the new provider — no restart, no env vars. - Exports
-
a49cfc2: Add
compareTofield toDashboardWidgetSchemaandvariant/dashArray/opacitytoChartSeriesSchemaso renderers can express period-over-period overlays on metric / gauge / chart widgets.compareToaccepts'previousPeriod','previousYear', or{ offset: '7d' | '4w' | '1M' | '1y' }. The renderer issues a second query against the shifted filter and either (a) derives a trend delta for KPI widgets or (b) overlays a muted comparison series on cartesian charts.
- Fix: update
package.jsonexportsto use nestedimport/requireconditions with per-conditiontypesfields (e.g.import.types → index.d.mts,require.types → index.d.ts). This ensures TypeScript withmoduleResolution: "bundler"resolves to the ESM declaration file (.d.mts) which uses explicit.mjschunk imports — eliminating the intermittent TS2306 "is not a module" error that occurred when tsup's DTS worker processed the CJS declaration chain.
-
f8651cc: Knowledge Protocol MVP — protocol-first RAG via adapter plugins.
What's new:
@objectstack/spec— newKnowledgeSource/KnowledgeDocument/KnowledgeChunk/KnowledgeHitschemas (under@objectstack/spec/ai) andIKnowledgeService/IKnowledgeAdaptercontracts (under@objectstack/spec/contracts).@objectstack/service-knowledge—KnowledgeServiceorchestrator +KnowledgeServicePlugin. Routes search/index calls to the appropriate adapter, runs permission-aware retrieval by re-checking every hit'ssourceRecordIdagainst the caller'sExecutionContextviaIDataEngine(same RLS that gates plain ObjectQL), and subscribes toIRealtimeServicefor inline record→adapter sync.@objectstack/knowledge-memory— deterministic, dependency-free in-memory adapter for dev/tests/reference. Hash-token embedder + brute-force cosine + paragraph chunking.@objectstack/knowledge-ragflow— production-grade adapter against the Apache-2.0 RAGFlow REST API. Plug in your dataset id; ObjectStack handles permission filtering after retrieval.@objectstack/service-ai— newsearch_knowledgetool wired through the registry. Threads the LLM caller's actor intoKnowledgeService.searchso retrieval honours RLS automatically.
Why this design: ObjectStack does NOT own chunking / embedding / vector storage / rerank — those are commodity capabilities best handled by mature OSS (RAGFlow, LlamaIndex, Dify, …). What ObjectStack uniquely owns is the protocol + permission-aware orchestration on top.
See
content/docs/protocol/knowledge.mdxfor the full design. -
f8651cc: AI tools now execute with the end-user's
ExecutionContext, so the existing ObjectQL row-level-security rules automatically scope what an agent can read and mutate.What changed
- New
ToolExecutionContext(on@objectstack/spec/contracts'sChatWithToolsOptions) carries the authenticated actor, conversation id, and environment id through to tool handlers. - The built-in data tools (
query_records,get_record,aggregate_data, legacyquery_data) and the auto-generatedaction_*tools now passoptions.contexttoIDataEnginecalls, mapping the actor to{ userId, roles, permissions, isSystem: false }. - Assistant + agent REST routes forward
req.userinto the new context automatically — no caller changes required. - When no actor is provided (cron jobs, internal callers, existing tests)
the helpers fall back to
{ isSystem: true }, preserving today's behaviour. Fully backward compatible.
Why this matters
Before this change, an AI tool call ran with system privileges and saw every row in the tenant. Now the agent sees exactly what the human operator would see — same RLS, same field-level masking, same audit trail. This is the foundation for trustworthy autonomous agents.
For custom call sites
If you invoke
aiService.chatWithTools(...)from your own route, passtoolExecutionContext: { actor: { id, roles, permissions } }to inherit the user's permissions. Omit it to keep the legacy system-level behaviour. - New
-
0bf6f9a: Add
Portalmetadata kind for external-user UI projections.A
Portaldeclares a public-facing "site" derived from an existingApp(or a curated subset of objects/views), with its own theme, authentication mode (anonymous / passwordless / sso), custom routes, and per-route guards. This is the protocol surface for the "customer portal" use case — partner sites, public booking, support knowledge bases — without forking the back-officeApp.New exports under
@objectstack/spec/ui:PortalSchema,Portal— Zod schema + inferred type.PortalRouteSchema,PortalRoute— per-route configuration (view ref, layout, auth requirement, sharing scope).PortalAuthModeSchema— enum of auth strategies (anonymous,passwordless,oauth,sso).definePortal()— DX builder mirroringdefineApp().
Stack composition:
composeStacks()now accepts and mergesportalsalongsideapps,objects,views, etc.No runtime / app behaviour change — this ships the protocol contract first so plugins, Studio, and the runtime can land Portal support in subsequent releases.
-
b4c74a9: Actions-as-tools Phase 3 — Human-In-The-Loop approval queue.
Dangerous declarative actions (
confirmText,mode:'delete',variant:'danger') can now be exposed to the LLM safely. Instead of being skipped outright, they are registered as tools whose handler enqueues a pending request and returns{ status: 'pending_approval', pendingActionId }to the model. A human approves (or rejects) from Studio's pending-actions inbox; the service then re-runs the exact same dispatcher.- New system object
ai_pending_actions(id, conversation_id?, message_id?, object_name, action_name, tool_name, tool_input, status [pending|approved|executed|failed|rejected], result?, error?, rejection_reason?, proposed_by, decided_by?, proposed_at, decided_at?). - New built-in Studio view
AiPendingActionViewwithpending/executed/rejected/failedsub-views and per-row Approve / Reject API actions. - New methods on
IAIService(all optional, gated on a wiredIDataEngine):proposePendingAction(input) → { id }approvePendingAction(id, actorId) → { status, result?, error? }rejectPendingAction(id, actorId, reason?)listPendingActions(filter?) → PendingActionRow[]
- New exported types:
PendingActionStatus,ProposePendingActionInput,PendingActionRow. - New REST routes (auth required):
GET /api/v1/ai/pending-actions(ai:read)GET /api/v1/ai/pending-actions/:id(ai:read)POST /api/v1/ai/pending-actions/:id/approve(ai:approve)POST /api/v1/ai/pending-actions/:id/reject(ai:approve)
- New exported predicate
actionRequiresApproval(action)for Studio's exposure surface.
AIServicePluginOptionsgainsenableActionApproval?: boolean(defaultfalse). Whentrueand anIDataEngineis available, dangerous actions are registered and routed through the queue.kernel.use( new AIServicePlugin({ enableActionApproval: true, // opt in apiActionBaseUrl: "http://localhost:3000", }) );
actionSkipReason()acceptsenableActionApproval+aiServicein its ctx and stops returning"requires confirmation"/"mode='delete'"/"variant='danger'"when HITL is wired.registerActionsAsTools()pre-registers a bypass-approval dispatcher per dangerous tool viaaiService.registerPendingActionDispatcher(toolName, fn); approval calls back into the same code path withenableActionApprovalflipped off, so a single handler implementation serves both proposal and execution.createActionToolHandler()short-circuits toproposePendingAction()whenenableActionApproval && actionRequiresApproval(action) && ctx.aiService?.proposePendingAction.
Slack/email notifications, approver routing (any signed-in user can approve in v1), auto-expiry of pending requests, resuming the same LLM turn after approval (operators get a fresh assistant message instead).
- New system object
-
93c0589: AI v1: Actions-as-Tools — every declarative UI
Actionoftype: 'script'is now auto-exposed as an AI-callable tool namedaction_<name>. Agents can perform business operations ("complete the groceries task") via natural language, routed through the samedataEngine.executeAction()dispatcher Studio uses. This is the write-side counterpart toquery_data.Highlights
registerActionsAsTools(toolRegistry, { metadata, dataEngine })walks every object'sactions[]and registers script-type ones, auto-injecting arecordIdargument for row-context actions and inheriting JSON-Schema parameter types from the owning object's fields.- Safety filters skip destructive actions by default:
confirmText,mode: 'delete',variant: 'danger', or explicitaiExposed: false. - New
aiExposed?: booleanflag onActionSchemafor fine-grained opt-out. - New
actions_executorskill bundle subscribes toaction_*(wildcard tool names now supported inSkillSchema.tools). - The built-in
data_chatagent now references bothdata_explorerandactions_executorskills, so users get read + write capabilities out of the box. MemoryLLMAdapterlearned a small two-step heuristic — when it sees an action verb ("complete", "start", "clone", ...) it routes to the matchingaction_*tool, resolvingrecordIdfrom any priorquery_dataresult.- New
examples/app-todo/test/ai-action.test.tsdemo proves the loop: user says "please complete the groceries task" → agent finds the task → agent callsaction_complete_task→ task status flips →ai_tracesrecords the run.
Breaking changes
None.
aiExposedis additive; existing actions remain exposed unless they fail an existing safety filter.Phase-1 limitations (Phase-2 roadmap items)
- Only
type: 'script'actions;api/flow/url/modal/formskipped. - No human-in-the-loop approval flow for destructive actions yet.
- No CEL evaluation of
visible/disabledpredicates against agent context. - No bulk action support (single-record only).
-
629a716: # v1 AI Protocol focusing — remove application-template schemas
The
@objectstack/spec/aiprotocol is reduced to only the primitives the runtime directly consumes. Eight schemas that described application templates or product features (not platform contracts) are removed; three more are slimmed to their primitive cores.File Reason for removal ai/devops-agent.zod.tsA specific Agent template, not a primitive. Compose with Agent + Skill + Tool.ai/plugin-development.zod.tsSpecific workflow; same reasoning. ai/runtime-ops.zod.tsAIOps is a vertical product, not a backend platform concern. ai/predictive.zod.tsML pipeline product (DataRobot/H2O space), orthogonal to metadata-driven backend. ai/agent-action.zod.ts100% conceptual overlap with tool+flow.ai/orchestration.zod.tsMulti-agent plans can be expressed as agents-as-tools. Premature. ai/nlq.zod.tsNLQ is LLM-native capability + a query_datatool over ObjectQL, not a protocol.ai/feedback-loop.zod.tsRLHF / training-side concern; not platform-owned. ai/rag-pipeline.zod.ts→ai/embedding.zod.ts(318 → 80 lines). KeepsEmbeddingModelSchema+VectorStoreSchemaprimitives. Removed: chunking strategies, retrieval pipelines, rerankers, document loaders, end-to-end RAG pipeline DSL. TheragPipelinesfield ondefineStack()is removed.ai/cost.zod.ts→ai/usage.zod.ts(431 → ~70 lines). KeepsTokenUsageSchema+AIUsageRecordSchema. Model pricing is the canonicalModelPricingSchemaalready exported fromai/model-registry.zod.ts. Removed: budget definitions, enforcement, alerts, allocation reports, optimization recommendations.ai/mcp.zod.ts(629 → ~100 lines). Defines only how to reference an external MCP server and bind its tools to an agent. The MCP protocol itself is owned by Anthropic's published spec and the@modelcontextprotocol/sdk; we no longer re-declare transport/capability/resource/prompt/streaming/sampling shapes.
No production code in this repository depended on the removed schemas. Downstream consumers that imported any of the removed types from
@objectstack/spec/aimust:- Remove the import. The platform no longer provides these types.
- Define your own application-level shape in your project / plugin
if you still need the concept. The primitives (
Agent,Skill,Tool,Conversation,Embedding,Usage,MCP{ServerRef,ToolBinding}) are sufficient to express every removed schema. - For RAG: replace
RAGPipelineConfigwith your own pipeline description built onEmbeddingModelSchema+VectorStoreSchema. - For cost: replace budget enforcement with your own service built
on
AIUsageRecordSchemarecords.
The platform's job is to define primitives that any AI feature can be built on top of, leveraging the metadata-driven nature of ObjectStack. The removed schemas described specific product features (DevOps agent, AIOps, RAG pipeline DSL, budget enforcement) that should live in plugins or applications — not in the canonical protocol. Shipping a 6,245-line AI protocol where 80% of it has no runtime implementation creates false promises to integrators.
After this change the AI protocol is:
ai/ ├── agent.zod.ts ← who ├── skill.zod.ts ← when ├── tool.zod.ts ← what ├── conversation.zod.ts ← what to remember ├── model-registry.zod.ts ← which LLMs ├── embedding.zod.ts ← embedding + vector store primitives ├── usage.zod.ts ← token + cost accounting └── mcp.zod.ts ← external ecosystem bridge8 files, ~1,200 lines. Every schema has a runtime implementation in
@objectstack/service-aior@objectstack/plugin-mcp-server. -
944f187: # v5.0 —
project→environmenthard renameThe runtime concept previously called "project" (per-tenant business workspace; Org → Project → Branch hierarchy; per-project ObjectKernel, per-project DB, per-project artifact) is now uniformly called "environment".
This is a hard rename with no aliases, deprecation shims, or compatibility layer. Upgrade requires a coordinated update of CLI, runtime, server, and any clients calling the REST API.
Note: "project" in the npm / monorepo sense (the framework itself,
package.json, tsconfig project references, vitestprojectsconfig) is unchanged.- Flags renamed:
--project/-p→--environment/-e(os publish,os rollback)--project-id→--environment-id(os dev)
- Default local env id:
proj_local→env_local. - Env var:
OS_PROJECT_ID→OS_ENVIRONMENT_ID. - Command group renamed:
os projects ...→os environments ...(bind,create,list,show,switch). - Persisted auth-config key:
activeProjectId→activeEnvironmentId.
- Scoped routes:
/api/v1/projects/:projectId/...→/api/v1/environments/:environmentId/.... - Cloud control-plane routes:
/api/v1/cloud/projects/...→/api/v1/cloud/environments/...(including/cloud/environments/:id/artifact,/cloud/environments/:id/metadata,/cloud/environments/:id/credentials/rotate, etc.). - Header:
X-Project-Id(and lowercasex-project-id) →X-Environment-Id(x-environment-id). - Route param name in handlers:
req.params.projectId→req.params.environmentId. - Hostname-routing and tenant-resolution code-paths use
environmentIdend-to-end.
- Exported symbols (no aliases):
createSystemProjectPlugin→createSystemEnvironmentPluginSYSTEM_PROJECT_ID→SYSTEM_ENVIRONMENT_IDProjectArtifactSchema→EnvironmentArtifactSchemaPROJECT_ARTIFACT_SCHEMA_VERSION→ENVIRONMENT_ARTIFACT_SCHEMA_VERSIONObjectOSProjectPlugin→ObjectOSEnvironmentPlugincreateSingleProjectPlugin→createSingleEnvironmentPlugin
- Plugin identifier strings:
com.objectstack.runtime.objectos-project→objectos-environmentcom.objectstack.studio.single-project→single-environmentcom.objectstack.multi-project→multi-environmentcom.objectstack.runtime.system-project→system-environment
- Provisioning hook:
provisionSystemProject→provisionSystemEnvironment.
- Column renames on
sys_metadataandsys_metadata_history:project_id→environment_id. - Column renames on
sys_activity:project_id→environment_id(plus index). - Object renames in platform-objects metadata:
sys_project→sys_environment(lookup targets),sys_project_member→sys_environment_member,sys_project_credential→sys_environment_credential. - Auth-context field:
active_project_id→active_environment_id. - JSON schemas under
packages/spec/json-schema/system/:ProjectArtifact*.json→EnvironmentArtifact*.json(regenerated at build).
A new migration
migrateProjectIdToEnvironmentId(packages/metadata/src/migrations/migrate-project-id-to-environment-id.ts) auto-runs fromDatabaseLoader.ensureSchema()on bootstrap and rewrites any existingproject_idcolumn onsys_metadata/sys_metadata_historytoenvironment_id(idempotent, best-effort). Existing rows are preserved.The legacy reverse migration
migrateEnvIdToProjectIdis retained verbatim for historical / disaster-recovery use; it is not auto-run.-os publish --project proj_xyz +os publish --environment env_xyz -curl -H "X-Project-Id: env_xyz" https://api.example.com/api/v1/data/customer +curl -H "X-Environment-Id: env_xyz" https://api.example.com/api/v1/data/customer -OS_PROJECT_ID=env_xyz os dev +OS_ENVIRONMENT_ID=env_xyz os dev -import { createSystemProjectPlugin, SYSTEM_PROJECT_ID } from "@objectstack/runtime"; +import { createSystemEnvironmentPlugin, SYSTEM_ENVIRONMENT_ID } from "@objectstack/runtime"; -import { ProjectArtifactSchema } from "@objectstack/spec"; +import { EnvironmentArtifactSchema } from "@objectstack/spec";
If you maintain a Cloud control-plane deployment, the
cloudrepository must be updated in lockstep to pick up the new plugin identifier strings (single-environment,multi-environment,objectos-environment). - Flags renamed:
-
dbc4f7d: feat(ai): v1 AI capabilities — ModelRegistry, structured output, tracing, schema retrieval, and
query_datatoolThis release lights up the first concrete capabilities on the slimmed AI protocol. All additions are non-breaking — new contract methods are optional and existing callers keep working unchanged.
-
ModelRegistry (
@objectstack/service-ai): in-memory runtime registry forAI.ModelConfig. Wire models viaAIServicePluginOptions.models/defaultModelId. Exposesget,getOrThrow,getDefault,list, andestimateCost(modelId, usage)for ex-post token cost computation. -
ai_traces object + auto-tracing: every LLM call from
AIService(chat,complete,stream_chat,chat_with_tools,generate_object,embed) is now instrumented with latency, token usage, status, and (when pricing is registered) cost. The defaultObjectQLTraceRecorderis auto-wired when the runtime exposes anIDataEngine, persisting rows to the newai_tracesobject. Drop in a customTraceRecorderviaAIServicePluginOptions.traceRecorder, or passnullto opt out. -
Structured output (
IAIService.generateObject): new optional method onIAIServiceandLLMAdapterthat returns a parsed, schema-validated object instead of free-form text. Implemented end-to-end inVercelLLMAdapter(uses the AI SDK'sgenerateObject— provider strict-mode is automatic when supported).MemoryLLMAdapterships a deterministic heuristic implementation so tests and demos work without an API key. -
SchemaRetriever: lightweight keyword-based retriever over
IMetadataService.listObjects(). Scores by object name (×3), label/plural (×2), description (×1), field name (×2), and field label (×1) with English stop-word filtering. Tokenisation splits snake_case sotodo_taskin a query matchesname: 'todo_task'.SchemaRetriever.renderSnippet()produces a Markdown block ready to inject into a system prompt — no embeddings, no extra infra. -
query_datatool: auto-registered when AI + Metadata + Data engine are all present. Takes a natural-languagerequest, retrieves relevant schemas, asks the model for a structuredQueryPlanviagenerateObject, validates the plan targets a real object, and executes it throughIDataEngine.find. Returns{ plan, count, records }. The composed primitive that closes the loop from "ask in English" → "validated SQL-shaped result". -
Working demo in
examples/app-todo:pnpm --filter @example/app-todo test:aiboots the full Todo stack, invokesquery_dataagainst the seeded tasks, and verifies the call lands inai_traces. Zero API keys, ~3 seconds end-to-end. Serves as the canonical reference for wiring AI into a real app.
- Strict tool schemas: nested
orderByandaggregationsitems indata-toolsnow declareadditionalProperties: false+required, matching the top-level contract and making them safe for provider strict mode.
TraceOperationvalues are now snake_case (stream_chat,chat_with_tools,generate_object) to match the project's data-value convention and so theai_traces.operationselect validates. CustomTraceRecorderimplementations that hard-code the old camelCase names need to be updated. The values are an internal observability artefact — no public protocol surface exposes them.
zodis now a direct dependency of@objectstack/service-ai(previously transitive viaai) because contract signatures and the new tool definition usez.ZodTypetypes directly.- All new methods on
IAIService/LLMAdapterare optional — existing custom adapters and callers continue to work without changes. - 12 new unit tests cover
ModelRegistry(cost math, defaults, throwing lookups) andSchemaRetriever(scoring, snake_case tokenisation, limits, snippet rendering). Full suite: 323/323 ✓.
-
-
fa011d8: feat(studio): metadata history timeline viewer
Adds a new
historyview mode that surfaces the audit timeline produced bysys_metadata_history(ADR-0008 §5) inside Studio. Available for every metadata type as a wildcard built-in plugin.@objectstack/spec: extendViewModeSchemawith'history'.@objectstack/studio: newhistoryViewerPluginrendering an event timeline (create/update/delete/rename) with op icons, short hash, actor, source, expandable detail panel. ADR-0009executionPinnedtypes (flow,workflow,approval) show a "Pinned" badge explaining that historical versions are retained for in-flight executions.
Reads from the existing
GET /meta/:type/:name/historyREST endpoint viaclient.meta.getHistory(); no new server surface.
-
bab2b20: feat(approvals): execution-pinned approval processes (ADR-0009)
When an approval request is submitted, the engine now records a
process_hashonsys_approval_request— the sha256 of the approval process body resolved throughMetadataRepository. While the request is in flight,approve/reject/recallresolve the pinned process body viaMetadataRepository.getByHash. Upgrading the approval process definition mid-flight therefore no longer affects requests that already started against the previous version.Behavior:
sys_approval_requestgains aprocess_hashcolumn (text, nullable, read-only). Existing rows keep working — the engine falls back to the currentsys_approval_processprojection when the column is empty.ApprovalServiceOptionsaccepts an optionalmetadataRepo. When omitted (e.g. defining processes purely through the runtime API or in unit tests), pinning is silently disabled and the service behaves as before.ApprovalsServicePluginlooks up the metadata service from the kernel and wires its repository automatically.- The metadata-core local
MetadataTypeSchemaenum was realigned with the canonical@objectstack/spec/kernelenum (drift fix:approval,field,function,service, …).
This is the first user-visible consumer of the
executionPinnedcapability introduced in ADR-0009. -
b806f58: Scope
sys_uservisibility to fellow organization members.The default RLS policy on
sys_userwasid = current_user.id, which meant @-mention pickers, owner/assignee lookups, reviewer selectors and the user roster all returned just the current user. The RLS compiler doesn't support subqueries, so aid IN (SELECT user_id FROM sys_member ...)policy isn't expressible.This change:
- Pre-resolves
org_user_ids(the IDs of all users in the active org) intoExecutionContextin all three REST entry-point resolvers (@objectstack/rest,@objectstack/runtime,@objectstack/plugin-hono-server). - Adds the field to
ExecutionContextSchemaso it survives Zod parsing. - Adds an
org_user_idsfield to the RLS compiler's user context. - Adds a new
sys_user_org_memberspolicy (id IN (current_user.org_user_ids)) to bothmember_defaultandviewer_readonlypermission sets, alongside the existingsys_user_selfpolicy. The RLS compiler OR-combines them, so users see themselves AND their org collaborators.
Capped at 1000 members per request. Large enterprises should plug in a directory cache or split per workspace.
- Pre-resolves
-
75f4ee6: feat(metadata): introduce
executionPinnedcapability for runtime version pinning (ADR-0009)Adds a new capability flag on the metadata type registry so that types whose runtime transaction rows reference a specific historical version (flow, workflow, approval) get unified pinning behavior — instead of every business table re-implementing its own snapshot column.
MetadataTypeRegistryEntrySchemagainsexecutionPinned: boolean, enforced invariantexecutionPinned ⇒ supportsVersioning.flow,workflow,approvalflipped toexecutionPinned: true.approvalalso corrected tosupportsVersioning: true(it was wronglyfalse).MetadataRepository.getByHash(ref, hash)added to the interface. Production implementation inSysMetadataRepositoryresolves historical bodies throughsys_metadata_historykeyed by(organization_id, type, name, checksum). In-memory and FS repositories serve HEAD-only matches.sys_metadata_historygains an index on(organization_id, type, name, checksum)to keep hash lookups O(log n).HistoryCleanupManagerskips pinned types entirely (both age-based and count-based retention) — pinned-type history must never be GC'd.
See
docs/adr/0009-execution-pinned-metadata.mdfor full rationale and the list of rejected alternatives (no shared snapshot table, no inlined snapshot column). -
823d559: Remove
sys_metadata_history.metadata_idcolumn.The column was originally a
Field.lookupFK intosys_metadata.id, then downgraded to plaintextduring the M1 history-writes work so that DELETE tombstones could keep an orphaned ref. After M1 we concluded the column carries no business value:- Audit-time joins use
(organization_id, type, name, version), which is already a UNIQUE composite key. - The physical row id is a database-internal detail with no logical identity — it cannot follow an item through delete + recreate.
- No code reader was ever added.
This release removes the column outright:
- Dropped
metadata_idfromSysMetadataHistoryObject(@objectstack/platform-objects). - Dropped
metadataIdfromMetadataHistoryRecordSchema(@objectstack/spec). SysMetadataRepository.put/deleteno longer write the column.- Legacy
DatabaseLoader.createHistoryRecordno longer writes it;getHistoryRecord/queryHistoryfilter by(type, name)directly (no parent-row lookup needed). MetadataHistoryCleanupmaxVersionspolicy groups by(type, name)instead ofmetadata_id.
Migration: Drop the column from existing
sys_metadata_historytables in a follow-up SQL migration. Existing history rows remain queryable since(organization_id, type, name, version)is already the canonical lookup key. No consumer code should be readingmetadata_id— if you are, switch to(organization_id, type, name, version).See ADR-0008 §14 for the full rationale.
- Audit-time joins use
- 2f9073a: Add
_sectionstoObjectTranslationDataso per-section labels on detail pages can be authored alongside_viewsand_actions. Convention:objects.<object>._sections.<section_name>.label. Consumed by@object-ui/plugin-detailwhen sections declare a stablename.
-
2869891: feat: Optimistic Concurrency Control (OCC) via
If-MatchUpdate and Delete requests now accept an optional version token. When supplied, the protocol compares it against the record's current
updated_at(orversioncolumn when available) and rejects with409 CONCURRENT_UPDATEon mismatch, preventing silent overwrites when two clients edit the same record.Wire formats (opt-in, all server- and client-backward-compatible):
PATCH /data/{object}/{id}— supportsIf-Match: "<token>"header orexpectedVersion: "<token>"body field (body wins when both present).DELETE /data/{object}/{id}— supportsIf-Matchheader or?expectedVersion=...query param.- Conflict response:
409 { error, code: 'CONCURRENT_UPDATE', currentVersion, currentRecord }so the client can offer Reload / Overwrite / Cancel UX.
Behaviour
- Missing/empty version → no check (legacy callers unaffected).
- Record not found during the version probe → no check; the downstream write
produces a normal
404. - Object has no
updated_atcolumn → no check (explicit opt-out for objects without timestamps). - Quoted RFC-7232 tokens (
"…") are accepted and unquoted before comparison.
Client
client.data.update(resource, id, data, { ifMatch })andclient.data.delete(resource, id, { ifMatch })now forward the token as anIf-Matchheader.Application-level CAS (findOne + compare in protocol.ts) is used in this slice to avoid touching every storage driver. A small TOCTOU window remains; for the B2B record-editing latencies this protects against, it is more than sufficient. Drivers may later be upgraded to atomic
WHERE id=? AND updated_at=?writes for true CAS without changing the public API.Tests: 7 new cases in
protocol-data.test.tscover opt-in, match, mismatch, quote-stripping, no-timestamps, empty-token, and the delete path.
-
23db640:
record:highlightsnow accepts richer field items.Each entry in
fieldsmay be either a bare field name (backward compatible) or an object{ name, label?, icon?, type? }that lets the schema override the displayed label, attach a Lucide icon, or force a specific cell renderer without editing the underlying object metadata. Useful when the same field appears in multiple highlight strips with different framing (e.g. "Annual Revenue" vs "ARR") or when you want a tiny icon for status-like fields.
- 2108c30:
ActionParamSchema.requirednow defaults tofalse(was effectivelyundefined). Functionally equivalent for existing consumers (which check truthiness), but makes the parsed object shape complete and unblocks downstream type narrowing. Fixes pre-existing failing testaction.test.ts > should accept minimal action parameter.
- 15e0df6: chore: unify all package versions to a single patch release
- 326b66b: fix: studio CI test failures and metadata protocol mock handler improvements
- 5f659e9: fix ai
-
f08ffc3: Fix discovery API endpoint routing and protocol consistency.
Discovery route standardization:
- All adapters (Express, Fastify, Hono, NestJS, Next.js, Nuxt, SvelteKit) now mount the discovery endpoint at
{prefix}/discoveryinstead of{prefix}root. .well-known/objectstackredirects now point to{prefix}/discovery.- Client
connect()fallback URL changed from/api/v1to/api/v1/discovery. - Runtime dispatcher handles both
/discovery(standard) and/(legacy) for backward compatibility.
Schema & route alignment:
- Added
storage(service:file-storage) andfeed(service:data) routes toDEFAULT_DISPATCHER_ROUTES. - Added
feedanddiscoveryfields toApiRoutesSchema. - Unified
GetDiscoveryResponseSchemawithDiscoverySchemaas single source of truth. - Client
getRoute('feed')fallback updated from/api/v1/datato/api/v1/feed.
Type safety:
- Extracted
ApiRouteTypefromApiRouteskeys for type-safe client route resolution. - Removed
as anytype casting in client route access.
- All adapters (Express, Fastify, Hono, NestJS, Next.js, Nuxt, SvelteKit) now mount the discovery endpoint at
-
e0b0a78: Deprecate DataEngineQueryOptions in favor of QueryAST-aligned EngineQueryOptions.
Engine, Protocol, and Client now use standard QueryAST parameter names:
filter→whereselect→fieldssort→orderByskip→offsetpopulate→expandtop→limit
The old DataEngine* schemas and types are preserved with
@deprecatedmarkers for backward compatibility.
- AI Agent/Skill/Tool metadata protocol refactoring (aligned with Salesforce Agentforce, Microsoft Copilot Studio, ServiceNow Now Assist)
- Tool as first-class metadata (
src/ai/tool.zod.ts):ToolSchema,ToolCategorySchema,defineTool()factory. Fields: name, label, description, category, parameters (JSON Schema), outputSchema, objectName, requiresConfirmation, permissions, active, builtIn. - Skill as ability group (
src/ai/skill.zod.ts):SkillSchema,SkillTriggerConditionSchema,defineSkill()factory. Fields: name, label, description, instructions, tools (tool name references), triggerPhrases, triggerConditions, permissions, active. - Agent protocol updated: Added
skills: string[]for Agent→Skill→Tool architecture; existingtoolsretained as backward-compatible fallback. Addedpermissions: string[]for access control. - Metadata registry:
toolandskillregistered as first-class metadata types inMetadataTypeSchemaandDEFAULT_METADATA_TYPE_REGISTRY(domain:ai, filePatterns:**/*.tool.ts,**/*.skill.ts, etc.) - Exports:
defineTool,defineSkill,Tool,Skillexported from@objectstack/specroot and@objectstack/spec/aisubpath.
- Tool as first-class metadata (
-
46defbb: Fix filter operators (contains, notContains, startsWith, endsWith, between, null) broken across spec and memory driver
- Add
$notContainstoStringOperatorSchema,FieldOperatorsSchema,FILTER_OPERATORS, andFiltertype - Add
notcontains/not_containstoVALID_AST_OPERATORSandAST_OPERATOR_MAP - Fix memory driver
convertToMongoQuery()passthrough to normalize non-standard operators to Mingo-compatible format - Add
$notContainsand$nulloperators to memory matcher - Fix undefined value guard in memory matcher to exclude
$exists,$ne, and$null
- Add
- 850b546: Maintenance patch release
-
5901c29: feat: auto-merge actions into object metadata via objectName
- Added optional
objectNamefield toActionSchemafor associating actions with specific objects - Added optional
actionsfield toObjectSchemato hold object-scoped actions defineStack()andcomposeStacks()now auto-merge top-level actions withobjectNameinto their target object'sactionsarray- Added cross-reference validation for
action.objectNamereferencing undefined objects - Top-level
actionsarray is preserved for global access (platform overview, search) - Updated example apps (CRM, Todo) to use
objectNameon their action definitions
- Added optional
- 953d667: Add modal cross-reference validation, action handler examples, and action.mdx doc sync
- 0088830: Minor version release
- 92d9d99: Add auto-detect persistence strategy for memory driver: automatically selects localStorage (browser) or file system (Node.js) based on runtime environment
- d1e5d31: Fix UI protocol design issues
- 15e0df6: chore: unify all package versions to 3.0.8
- 5a968a2: Unify all package version numbers across the monorepo. All packages now share the same version and are released together via the changeset fixed group.
- 0119bd7: Implement DatabaseLoader for production metadata persistence
- 5426bdf: Migrate CLI architecture to oclif framework Improve chart
- 5df254c: Patch version release
- 23a4a68: Patch release for ObjectStack spec
- d738987: chore: patch release
- c7267f6: Patch release for maintenance updates and improvements.
-
28985f5: Breaking Change: Strict Validation Enabled by Default
defineStack()now validates configurations by default to enforce naming conventions and catch errors early.What Changed:
defineStack()now defaults tostrict: true(wasstrict: false)- Field names are now validated to ensure snake_case format
- Object names, field types, and all schema definitions are validated
Migration Guide:
If you have existing code that violates naming conventions:
// Before (would silently accept invalid names): defineStack({ manifest: {...}, objects: [{ name: 'my_object', fields: { firstName: { type: 'text' } // ❌ Invalid: camelCase } }] }); // After (will throw validation error): // Error: Field names must be lowercase snake_case // Fix: Use snake_case defineStack({ manifest: {...}, objects: [{ name: 'my_object', fields: { first_name: { type: 'text' } // ✅ Valid: snake_case } }] });
Temporary Workaround:
If you need to temporarily disable validation while fixing your code:
defineStack(config, { strict: false }); // Bypass validation
Why This Change:
- Catches Errors Early: Invalid field names caught during development, not runtime
- Enforces Conventions: Ensures consistent snake_case naming across all projects
- Prevents AI Hallucinations: AI-generated objects must follow proper conventions
- Database Compatibility: snake_case prevents case-sensitivity issues in queries
Impact:
- Projects with properly named fields (snake_case): ✅ No changes needed
- Projects with camelCase/PascalCase fields:
⚠️ Must update field names or usestrict: false
- 389725a: Fix build and test stability improvements
- Release v3.0.0 — unified version bump for all ObjectStack packages.
-
Modularized kernel/events.zod.ts into 6 focused sub-modules for better tree-shaking and maintainability:
- events/core.zod.ts: Priority, metadata, type definition, base event
- events/handlers.zod.ts: Event handlers, routes, persistence
- events/queue.zod.ts: Queue config, replay, sourcing
- events/dlq.zod.ts: Dead letter queue, event log entries
- events/integrations.zod.ts: Webhooks, message queues, notifications
- events/bus.zod.ts: Complete event bus config and helpers
kernel/events.zod.ts now re-exports from sub-modules (backward compatible). Created v3.0 migration guide.
- Patch release for maintenance and stability improvements
- Unify all package versions with a patch release
- Patch release for maintenance and stability improvements
- Patch release for maintenance and stability improvements
-
1db8559: chore: exclude generated json-schema from git tracking
- Add
packages/spec/json-schema/to.gitignore(1277 generated files, 5MB) - JSON schema files are still generated during
pnpm buildand included in npm publish viafilesfield - Fix studio module resolution logic for better compatibility
- Add
- Patch release for maintenance and stability improvements
- 38e5dd5: feat: Studio DX, REST extraction, Dispatcher plugin
- 38e5dd5: test minor bump
- chore: add Vercel deployment configs, simplify console runtime configuration
- a7f7b9d: fix(data): add missing expand, top, having, distinct fields to QuerySchema for OData/ObjectQL compatibility
- b1d24bd: refactor: migrate build system from tsc to tsup for faster builds
- Replaced
tscwithtsup(using esbuild) across all packages - Added shared
tsup.config.tsin workspace root - Added
tsupas workspace dev dependency - significantly improved build performance
- Replaced
-
a0a6c85: Infrastructure and development tooling improvements
- Add changeset configuration for automated version management
- Add comprehensive GitHub Actions workflows (CI, CodeQL, linting, releases)
- Add development configuration files (.cursorrules, .github/prompts)
- Add documentation files (ARCHITECTURE.md, CONTRIBUTING.md, workflows docs)
- Update test script configuration in package.json
- Add @objectstack/cli to devDependencies for better development experience
-
109fc5b: Unified patch release to align all package versions.
- Major version release for ObjectStack Protocol v1.0.
- Stabilized Protocol Definitions
- Enhanced Runtime Plugin Support
- Fixed Type Compliance across Monorepo
- Refactor documentation architecture and terminology (Data/System/UI Protocols).
- Patch release for maintenance and stability improvements. All packages updated with unified versioning.
-
555e6a7: Refactor: Deprecated View Storage protocol in favor of Metadata Views.
- BREAKING: Removed
view-storage.zod.tsandViewStoragerelated types from@objectstack/spec. - BREAKING: Removed
createView,updateView,deleteView,listViewsfromObjectStackProtocolinterface. - BREAKING: Removed in-memory View Storage implementation from
@objectstack/objectql. - UPDATE:
@objectstack/plugin-mswnow dynamically loads@objectstack/objectqlto avoid hard dependencies.
- BREAKING: Removed
-
This release includes a major upgrade to the core validation engine (Zod v4) and aligns all protocol definitions with stricter type safety.
- fb41cc0: Patch release: Updated documentation and JSON schemas
- Patch release for maintenance and stability improvements
- Patch release for maintenance and stability improvements
-
b2df5f7: Unified version bump to 0.5.0
- Standardized all package versions to 0.5.0 across the monorepo
- Fixed driver-memory package.json paths for proper module resolution
- Ensured all packages are in sync for the 0.5.0 release
- Unify all package versions to 0.4.2
-
Version synchronization and dependency updates
- Synchronized plugin-msw version to 0.4.1
- Updated runtime peer dependency versions to ^0.4.1
- Fixed internal dependency version mismatches
- Release version 0.4.0
-
Workflow and configuration improvements
- Enhanced GitHub workflows for CI, release, and PR automation
- Added comprehensive prompt templates for different protocol areas
- Improved project documentation and automation guides
- Updated changeset configuration
- Added cursor rules for better development experience
- Patch release for maintenance and stability improvements
-
Documentation and project structure improvements
- Comprehensive documentation structure with CONTRIBUTING.md
- Documentation hub at docs/README.md
- Standards documentation (naming-conventions, api-design, error-handling)
- Architecture deep dives (data-layer, ui-layer, system-layer)
- Code of Conduct
- Enhanced documentation organization following industry best practices
-
Initial release of ObjectStack Protocol & Specification packages
This is the first public release of the ObjectStack ecosystem, providing:
- Core protocol definitions and TypeScript types
- ObjectQL query language and runtime
- Memory driver for in-memory data storage
- Client library for interacting with ObjectStack
- Hono server plugin for REST API endpoints
- Complete JSON schema generation for all specifications
- Remove debug logs from registry and protocol modules
- b58a0ef: Initial release of ObjectStack Protocol & Specification.