Skip to content

Latest commit

 

History

History
3671 lines (2889 loc) · 184 KB

File metadata and controls

3671 lines (2889 loc) · 184 KB

@objectstack/service-automation

17.0.0-rc.0

Major Changes

  • 83c161f: feat(automation)!: a flow run with no trigger user may no longer touch data (#3760)

    An effective runAs:'user' run that resolves no trigger user used to execute its data nodes UNSCOPED — it presented no principal, and the data security middleware skips when there is no principal, so the run read and wrote every row. runAs:'user' is an access-narrowing declaration; failing to resolve it must never resolve to a grant (ADR-0049). It now refuses the operation (UnscopedRunDataAccessError), naming runAs:'system' as the fix.

    This was never really about schedules. The docs, the spec, the runtime warning and the lint all described a schedule-shaped problem, and the lint only ever matched that shape. But the runtime predicate is "no user", and the commonest way to have no user is a record-change flow fired by a write that carried none: isSystem does not suppress trigger dispatch — only skipTriggers does, and exactly three first-party paths set it — so every plugin/service system write, the approvals status mirror, and a runAs:'system' flow's own data node dispatched record-change flows with userId: undefined. Ordinary users reach those writes routinely (submitting for approval mirrors a status onto the target record), so the fail-open was reachable by unprivileged input and was the common case, not the rare one.

    Deliberately not implemented as "inherit the triggering write's posture and run as isSystem". That reads like a relabel but is a privilege escalation: the security middleware's isSystem short-circuit fires before its package-managed-row, system-row, audience-anchor and delegated-admin gates, all of which a principal-less context still has to clear. Such a run cannot write sys_user_position today; as isSystem it could. "Unscoped" was never equivalent to "system".

    Breaking — how to migrate. A flow that reacts to system writes and needs to act beyond one user's grants declares runAs: 'system', making the elevation explicit and audit-attributable. Otherwise ensure the trigger supplies a user. Flows that touch no data are unaffected (runAs is moot), and the failure is isolated: the trigger already swallows flow errors, so the originating write still succeeds. The engine warns at run setup, before any node executes.

    #3712's user-less provenance path is subsumed, not broken. That fix let a run with no trigger user write its own approval-locked record by carrying a provenance-only ObjectQL context (the run id, nothing else). Such a run can no longer perform a data operation at all — presenting no principal is exactly what made the write unscoped — so it is refused before the lock is consulted. The capability survives via the explicit route: a schedule that must write records declares runAs:'system', which the lock hook exempts on its own isSystem branch. The flowRunId exemption itself stays live and load-bearing for what #3703 built it for — a runAs:'user' run that does have a user — where the exemption is still provenance rather than privilege.

    Also in this change:

    • flow-schedule-runas-unscopedflow-runas-unscoped, and it now fails the build. It read as a gate and behaved as a comment — os compile documented that the flow lint "NEVER fails the build" — which is close to no net at all for the audience it protects, very often an AI generating flows in bulk. It now also covers the other provably user-less triggers (time_relative, api), per ADR-0073 D5. It still cannot cover record_change, which is undecidable at authoring time — that is exactly why the runtime refusal exists.
    • Three seed writes stopped firing automation. The seed loader's pass-2 deferred-reference back-fill and both of AppPlugin's basic-insert fallbacks inlined a bare { isSystem: true } instead of the shared seed options, so they seeded with record-change automation live — the self-trigger vector skipTriggers exists to prevent, on the writes that skipped it.
    • ADR-0073 amended. Its severity rationale ("an unprivileged user cannot trigger a schedule, so there is no untrusted-input path") is falsified, and its rejection of fail-closed ("breaks legitimate scheduled CRUD — 2/3 example flows relied on the default") expired when those flows were fixed to declare runAs:'system'. Refusal is an interim posture, forward-compatible with the ADR's automation principal: when that lands, the refusal point becomes the place that resolves it.

Minor Changes

  • 57a3bb3: fix(automation,approvals): the run-resume route is gated by the node the run is parked on (#3801)

    POST /api/v1/automation/:name/runs/:runId/resume forwarded a caller-supplied { inputs, output, branchLabel } straight into AutomationEngine.resume, and resumeInternal validated machine state only — the concurrent-resume latch, the run exists, the flow exists, the suspended node still exists. Nothing asked who was calling.

    Approval nodes suspend and resume through exactly that mechanism. So a resume carrying branchLabel: 'approve' walked the approve edge with no approver check, no sys_approval_action row and no status mirror — the sys_approval_request row and the run then disagreed permanently. The only thing standing between the route and the approvals rules was convention; the showcase spelled it out in a comment ("decide via the approvals API, never a raw engine resume"), and a comment in an example is not an access control.

    Removing the route was not the fix: it is load-bearing for screen flows — the UI flow-runner posts { inputs } there to advance a paused screen node. The gate therefore keys on what the run is parked on:

    • ActionDescriptor.resumeAuthority ('any' | 'service', default 'any') — a pausing node declares who may continue it. approval declares 'service'.
    • The engine refuses a 'service' suspension unless the signal carries RESUME_AUTHORITY_SERVICE (@objectstack/spec/contracts), a symbol the owning service stamps in-process — a JSON body can never produce one, so the transport cannot forge it. ApprovalService stamps it on the tail of a decision it has already authorized and recorded.
    • The gate follows a subflow pause down to the child the signal would actually reach, so resuming the parent is not a way around it.
    • Refusal returns { success: false, code: 'forbidden' } and the route answers 403. Nothing is consumed — the request stays pending and the run stays parked, so the real decision still lands.

    screen and wait pauses are unchanged, as is every path that already went through the approvals API. What changes for consumers:

    • FROM: finishing an approval with client.automation.resume(flow, runId, { branchLabel: 'approve' }) TO: client.approvals.approve(requestId, …) (or .reject / .recall). The old call now answers 403 and changes nothing.
    • Registering your own pausing node whose continuation belongs to a service rather than to whoever holds the run id? Declare resumeAuthority: 'service' on its descriptor and stamp RESUME_AUTHORITY_SERVICE on the signal from that service.

    A suspension now records the node type that produced it (SuspendedRun.nodeType / sys_automation_run.node_type), captured at suspend time so a flow republished mid-pause cannot re-type the node out from under the gate; rows written before this fall back to the flow definition.

  • 2fa4ca1: Dynamic approver routing for approval nodes (#3447 P2) — three new declarative capabilities:

    expression approvers. A new approver type whose CEL expression resolves WHO approves at node entry, over exactly three roots: current.* (the record's live state), trigger.* (the submit-time snapshot) and vars.* (flow variables, incl. upstream node outputs). record and bare field names are rejected before evaluation — on this platform record always means "the record at event time", which is ambiguous at an approval node — with error messages that prescribe the correct spelling. The optional resolveAs: 'user' | 'department' | 'position' | 'team' re-expands each resolved id through the same graph lookups the static types use; with behavior: 'per_group' each intermediate value (e.g. each returned department) forms its own sign-off group. A missing key fails the node loudly; only a present-but-empty result counts as an empty slate.

    onEmptyApprovers policy. What an empty resolved slate does, node-level, for all approver types: admin_rescue (default — request opens for privileged takeover, the #3424 behaviour), fail (node fails), or auto_approve (skip the request, continue down the approve edge with output.autoApproved = true). To support auto-approve, the automation engine now honours NodeExecutionResult.branchLabel on the synchronous completion path — the field existed but was only ever consumed via resume signals.

    Decision outputs. decide(..., { outputs }) hands structured data from the approver to the flow: the author declares allowed keys on the node (decisionOutputs), approvers fill values only, and accepted outputs resume the run as <nodeId>.<key> variables — a later approval node's expression can read vars.<nodeId>.picked_departments, closing "the previous approver picks the next step's approvers" without a record-field detour. Undeclared keys reject the decision; decision/requestId are reserved. Multi-approver tallies now always pin to the open-time approver snapshot (previously unanimous re-resolved at each decision against the payload snapshot).

    Also: collectCelRootIdentifiers is exported from @objectstack/formula (shared by the new os lint rules and the runtime pre-check, so they can never drift), resolution inputs are audited on the request snapshot as __resolvedFrom, and three new lint rules gate expressions, empty-slate policies and reserved output keys at author time.

  • 2f47489: fix(automation): a fault edge must not switch off a guardrail (#3863)

    A fault edge routes a failed node to a handler instead of aborting the run. That is the right primitive for the world not cooperating — an http node that 404s, a connector that rate-limited, a rejected write.

    It was also, until now, routing the refuse-to-execute family. Those guards report that the METADATA is wrong, not that an operation failed: #3810 (interpolation erased a filter condition), ADR-0049/#1888 (the run would execute unscoped), a data node naming no object. Because they surfaced as ordinary node failures, one declared edge silently disabled them.

    The live consequence, reproduced in a test before the fix: attach a fault edge to a delete_record whose filter has a typo ({record.ownr}), and #3810's protection against emptying the object was gone — the guard fired, the handler swallowed it, and the run reported success: true. That is the exact fail-open direction #3810 was opened to close, reachable from a single edge, and it is the kind of suppression an AI authoring loop reaches for first when trying to make a diagnostic go away.

    Failures now carry a class. NodeExecutionResult.errorClass is 'runtime' (default — every existing executor keeps its current routing) or 'guard'. Guard-class failures are never routed: they stay fatal with or without a fault edge, and the run fails with the guard's own message. Thrown guards are covered too — UnscopedRunDataAccessError is branded via a shared guard-refusal module, so the engine's catch path cannot become the bypass the return path no longer is.

    Marked as guard-class: the three resolveNodeFilter refusals (#3810), the four objectName required refusals, and UnscopedRunDataAccessError (ADR-0049). Genuine engine failures (get_record(x) failed: …) stay runtime-class and keep routing.

    Also in this change

    • {<nodeId>.error} now carries a failed node's message alongside the run-wide {$error}. $error names only the most recent failure, so a handler shared by two fault edges could not tell which node it was handling; {charge_card.error} is addressable from any downstream template. Additive — $error is unchanged.
    • Fault edges are documented for the first time (content/docs/automation/flows.mdx and the automation skill), including the routable/not-routable split. The skill entry says plainly not to add a fault edge to silence a guard error, since that is the misuse the class split now makes impossible.

    A run that takes a fault branch still reports success, and the failed step still carries status: 'failure' and its message in the trace — recovery does not erase the record of what failed (#3356/#3407).

  • de9af8a: fix(automation,objectql): a filter that loses a condition must not run (#3810)

    Three related holes, all of which end in "the query matched rows the author excluded".

    1. A flow filter could silently widen to match everything.

    The flow template interpolator expresses "this token did not resolve" as undefined. In a message that renders as empty text — harmless. In a FILTER it removes the condition, and a removed condition matches MORE rows. When it was the only condition, { owner: '{record.ownr}' } became {}, and {} handed to deleteMany is every row in the table.

    So one mistyped field name in a delete_record node silently emptied the object. Reproduced with all four causes: a typo ({record.ownr}), an input the run never received, a lookup hop ({record.account.name} — the trigger record carries a scalar id), and a filter placeholder.

    get_record / update_record / delete_record now refuse to execute when interpolation erased any authored condition, naming the offending template. The guard keys on LOSS, not emptiness: an author who deliberately wrote no filter is unaffected, and losing one of two conditions still fails, because widening from "my open records" to "all open records" is the same class of bug.

    2. Filter placeholders never reached the engine that resolves them.

    config.filter is where two {…} dialects meet — the flow template dialect ({record.owner}) and the filter placeholder dialect ({current_year_start}, {current_user_id}, resolved by resolveFilterTokens()). Evaluation order picked the winner by accident: the flow interpolator ran first, found no flow variable by that name, and erased it.

    interpolateFilter() hands that position back to the dialect that owns it — a whole-string token that no flow variable resolves and that IS a recognised placeholder passes through verbatim for the engine to expand. Flow variables keep precedence, so a template that works today cannot change meaning.

    3. The engine resolved placeholders on reads but not on writes.

    resolveFilterTokens() reached find/findOne/count/aggregate only. So the SAME filter selected different rows depending on the verb: find({ owner: '{current_user_id}' }) matched the signed-in user's rows, while update/delete compared the literal token text and matched none — a flow that previewed with one and acted with the other operated on two different row sets. This is the #3106 shape one layer down: the evaluator existed, only some call sites reached it.

    update and delete now resolve too, BEFORE the by-id fast path claims a scalar where.id (otherwise an unresolved {current_user_id} would be bound as the primary key itself). Caller options are never mutated.

  • 5524f84: feat(automation): opt-in single-hop lookup expansion for record-change flow templates (#3475)

    A record-change flow can now declare expand: ['<lookup_field>', …] on its start node config so node templates resolve {record.<lookup>.<field>} (e.g. {record.account.name} in a notify title, closing the #3426 gap for lookups).

    The engine re-reads the declared relations AFTER identity resolution, as the run's OWN principal — resolveRunDataContext honors runAs, so a runAs:'user' run reads the referenced object as the triggering user (its RLS/FLS enforced) rather than system-elevated. This is what made expansion unsafe to do in the trigger's re-read (which has no resolved grants) and is why it lives in the engine (new AutomationEngine.setRecordExpander, bridged by the plugin to the same data engine the CRUD nodes use).

    Only the declared relation keys are grafted onto the run record, so bare lookup ids and multiple lookup arrays (#1872) on other relations — and the formula fields the trigger already hydrated — are untouched. Opt-in ⇒ zero cost when unused; best-effort ⇒ a re-read failure leaves the record unexpanded and never breaks the flow.

    The os validate lint rule flow-template-lookup-traversal (#3426/#3472) is now suppressed for a relation once the flow declares it in config.expand.

  • 7687f7b: fix(automation): a screen field's visibleWhen reaches the client (#3528)

    visibleWhen has been on the screen node's designer form since #3304 — declared as an expression (xExpression), documented as bare CEL, offered to authors in Studio. The executor never put it on the wire. ScreenFieldSpec carried name / label / type / required / options / defaultValue / placeholder and nothing else, so no client could honour a predicate it never received. Authors wrote conditional visibility; every field rendered unconditionally; nothing errored.

    That is worse than a cosmetic miss, because required is honoured. A field that is optional-by-design but required when shown becomes permanently required once its predicate is dropped — and a runner that validates the full field list then blocks Submit on input the user was never asked for. No resume request is issued and the run sits paused forever. HotCRM's lead-conversion screen is exactly that shape:

    { name: 'createOpportunity', type: 'boolean', required: true },
    { name: 'opportunityName',   type: 'text', required: true,
      visibleWhen: 'createOpportunity == true' },

    Leave the checkbox unticked and opportunityName — which should not be on screen at all — blocks the whole conversion.

    • ScreenFieldSpec.visibleWhen is now part of the contract, documented as client-evaluated bare CEL over the screen's own field names, with the required-must-follow-visibility rule stated where implementors will read it.
    • The screen executor forwards it raw, deliberately uninterpolated: the predicate is re-evaluated per keystroke against values only the client has, so resolving it server-side against flow variables would freeze the field.
    • Covered by tests — the screen wire payload had none for this key.

    Clients must evaluate the predicate and skip hidden fields when enforcing required. Honouring one without the other reproduces the dead-end above.

  • b95577a: feat(automation): surface silently-stripped write fields as step warnings (#3407)

    update_record used to report an unconditional success even when the data layer legally stripped the requested write fields — static readonly (#2948) or a TRUE readonlyWhen predicate (#3042). The only trace was a server-side logger warn, invisible in the flow run trace: an author saw a clean 3ms success while the DB truth never changed (how #3356's approval stage write-backs failed unnoticed).

    • spec: new DroppedFieldsEventSchema / DroppedFieldsEvent ({ object, fields, reason: 'readonly' | 'readonly_when' }) in data/data-engine.zod.ts, and a WriteObservabilityOptions (onFieldsDropped listener) mixin on IDataEngine.insert/update option params in contracts/data-engine.ts. The listener is a TS-contract-level, in-process-only channel — deliberately NOT part of the serializable Zod options schemas or the RPC boundary.
    • objectql: engine.update() reports each strip pass's dropped keys + reason through options.onFieldsDropped (all four strip sites: single-id + bulk × readonly + readonlyWhen). A throwing listener never breaks the write. System-context writes skip the readonly strip and therefore report nothing, as before. insert() accepts the option for symmetry but strips nothing today (INSERT is readonly-exempt; FLS write denial throws).
    • service-automation: NodeExecutionResult and StepLogEntry gain advisory warnings?: string[]; update_record / create_record attach one warning per strip event naming the dropped fields, plus a structured droppedFields output ({<nodeId>.droppedFields}) for downstream nodes. success semantics are unchanged — stripping stays legal, it just is no longer silent.

Patch Changes

  • b949059: fix(approvals): a dead approval run no longer leaves the record RECORD_LOCKED (#3456)

    The record lock is keyed on a pending sys_approval_request, and it could not tell the run that owns that request from an unrelated user editing the record. So a flow that touched its own target record while its own approval was still pending — a manual resume with no decision, or a node that writes the record between opening the approval and the decision — died on its own RECORD_LOCKED, and the record stayed locked behind the dead run. Recovery existed (#3424 lets an admin recall/reject to release it) but nothing made it self-healing.

    Both halves are now closed.

    Prevention — the owning run may write its own record. The automation engine stamps flowRunId onto the run context at setup, alongside runAs, and it travels with every data node's ObjectQL context into ctx.provenance. The lock hook exempts a write whose flowRunId matches the pending request's flow_run_id. It is keyed on run identity rather than elevation on purpose: a runAs:'user' run stays fully RLS-scoped while it writes. flowRunId is pure provenance — server-constructed like isSystem, never client-supplied, evaluated by no security middleware, and the only write it permits is to the one record its own run already holds a pending request against.

    Recovery — a sweep releases records held by runs that died anyway. A pending request whose owning run has reached a terminal state (completed, failed, cancelled, timed_out) can never be decided, so it is finalised as recalled — releasing the lock — and audited under the reserved actor system:dead-run with the run and its status in the comment, so it is never mistaken for a submitter's withdrawal. It runs on the existing approvals sweep clock, which also covers the case no in-band handler can: a run killed by a process crash.

    The sweep is fail-safe by construction. It acts only on an explicit terminal status from a closed set; paused (the normal state of a live approval), running, an unrecognised status, an unknown run, a getRun that throws, and a deployment with no automation engine are all read as "still alive". The failure mode is "a dead run's lock survives until an admin recalls it" — today's behaviour — never "a live approval is destroyed".

    Also fixes AutomationEngine.getRun, which returned the first log entry for a run id rather than the latest. A run that pauses and later finishes records two entries under one id, so every suspend-then-finish run — every approval, screen and wait flow — reported itself as paused forever, both on the Runs observability surface and to this sweep.

    One shape was left out here and closed separately in #3712: a runAs:'user' run with no trigger user (a schedule) resolved no ObjectQL context at all, so it carried no flowRunId and stayed subject to the lock. It now passes a provenance-only context — the run id and nothing the security middleware keys on — so it is attributable without acquiring a principal, and its documented unscoped posture (#1888) is unchanged.

  • c5ff96d: fix(approvals): a schedule-triggered run can write its own locked record (#3712)

    #3456 let the run that opened a pending approval write its own target record, keyed on flowRunId. It worked for every run that resolves an identity and missed the one that doesn't: an effective runAs:'user' run with no trigger user — a schedule being the canonical case — passed no ObjectQL context at all, so nothing carried the run id and the run still died on its own RECORD_LOCKED.

    The blocker was never the lock. It was that "no identity" and "no context" were the same thing on the wire, so a run could not say who it was without also claiming what it was allowed to do.

    A run with no principal now passes provenance alone. resolveRunDataContext returns { flowRunId } — no userId, no positions, no permissions, not even isSystem: false. Every principal gate keys on one of those fields (the elevation short-circuit on isSystem, the ADR-0103 engine-owned write guard and the ADR-0090 D12 delegated-admin gate on userId, the empty-principal fall-open on all three), so this context authorizes identically to no context at all. The run keeps the documented #1888 unscoped posture, its loud [runAs] warning, and the flow-schedule-runas-unscoped build-time lint. Nothing about what it may touch changed — only that it can now be attributed.

    Provenance moved out of the hook session, into ctx.provenance. session answers who is calling and is absent when no identity envelope was supplied — a distinction real gates depend on (the attachment access gate skips bare-kernel writes on exactly that test). Folding a run id into session would have forced an identity-less run to present an empty session, silently turning "no caller" into "an anonymous caller" and narrowing the #1888 fail-open for attachments alone. HookContext.provenance.flowRunId says what produced the write; the approvals lock reads it there.

    Also relaxes BaseEngineOptionsSchema.context to a partial envelope (ExecutionContextInput). positions/permissions/isSystem carry parse-time defaults, which made them required on a caller-supplied option and asserted something untrue — that every data-engine context carries a principal. Callers have always passed slices ({ isSystem: true } for a system read); the type now says so.

    Migration: nothing to change unless you read the run id inside a hook. If you wrote ctx.session.flowRunId, read ctx.provenance.flowRunId instead — the field never shipped under the old name.

  • fb90784: fix(approvals): the status mirror names the human who caused the transition (#3783)

    When an approval moves, the service writes the new status onto the business record (approvalStatusField). That write is what fires the record-change flows bound to that object — so it is the seam "when the invoice is approved, do X" runs through. It presented a bare { isSystem: true } context with no userId, at six call sites that each know exactly who acted: a submitter submitting, an approver approving, rejecting, sending back, recalling.

    Combined with #3760 — which stopped letting a runAs:'user' run with no trigger user touch data — that identity gap made the most natural approvals automation there is unwritable in its obvious form. The cascade inherited no user, so its data nodes were refused, and the author's only way forward was to declare runAs: 'system' and take blanket elevation for a case where a perfectly good scoped identity existed at the call site all along.

    The mirror now carries the acting user. It stays isSystem — the record is normally locked while its approval is live, so only a platform write can land the status — because elevation and anonymity are separate choices, and this write only ever needed the first. Cascades now run as the deciding user with RLS enforced.

    • The identity is the authenticated principal, never the request body's actorId. actorId arrives from the caller (body.actorId ?? context.userId) and is only checked against the pending approver slate, never against the caller. That is tolerable on an audit row; promoting it to the identity of an RLS-scoped write would have turned a mislabelled audit trail into identity spoofing.
    • Approval-by-email-link is attributed too. ADR-0043 action links carry no session, so they used to decide as pure system. The single-use hashed token binds exactly one approver and is re-checked against the live slate at redemption — that is an authentication — so the redeemed decision now presents that approver, and an emailed approval cascades identically to one made in the UI.
    • The two machine-driven transitions stay user-less on purpose: the SLA escalation's auto-decision and the dead-run sweep. system:sla and system:dead-run are reserved audit actors, not users, and presenting one as a user would put a non-user in updated_by and in every downstream flow's identity. A flow that wants to react to those declares runAs:'system' — the honest answer, and now a deliberate one rather than an artefact.
    • Attribution only — the write is not newly org-scoped. On an ExecutionContext tenantId is a driver-scoping knob, not attribution (ObjectQL turns it into a tenant predicate), so passing the request's org would have silently no-op'd the mirror on a record whose org differs. The automation engine already back-fills a run's tenantId from the resolved user's grants.

    Visible change: the mirrored record's updated_by now names the acting user instead of retaining its previous value — ObjectQL's audit stamping is gated on the write context's userId alone, and isSystem buys no exemption. That is the attribution this fix is for: the approver who set the record to approved is now its last modifier.

  • 9dcc0ae: fix(automation): array-form flow triggerType fails loudly instead of silently never firing (#3481)

    An array triggerType on a flow start node — the shape an author (or an AI authoring pass) naturally reaches for to fire on more than one event, e.g.

    config: { objectName: 'app_task', triggerType: ['record-after-create', 'record-after-delete'] }

    was accepted everywhere and armed nowhere. Multi-event unions are deliberately unsupported (only the single tokens plus the record-after-write create-OR-update union exist — see #3457), but nothing said so: defineFlow passed the array (start-node config is an open record), the engine's typeof === 'string' check folded it to no trigger and misclassified the flow as manual, so it never entered the trigger-binding audit, and the flow-trigger-readiness lint used the same typeof narrowing and produced no finding. The flow bound to nothing and never fired, with zero output at any layer — the same silent-never-fire class as #3427 / #3472, and the last authoring shape still slipping past every guard.

    This is a defensive fix — arrays remain unsupported; they now fail loudly:

    • lint (validate-flow-trigger-readiness): an array triggerType containing any record-* element now yields a flow-trigger-unknown-event warning at os validate time, steering to record-after-write (for created-or-updated) or one flow per event.
    • engine (resolveTriggerBinding): such an array is routed to the record_change trigger — exactly as an unmappable single token is — instead of being folded to a manual flow, so it reaches the trigger's bind-time rejection.
    • trigger (record-change): the bind-time rejection detects the array shape and emits a targeted warning (naming the flow, pointing at record-after-write and #3457) rather than the generic unknown-token line.
  • 7ef20d0: feat(cli,automation): catch label: 'error' written where type: 'fault' was meant (#3863)

    Two of the three items left open on #3863. Both are about making the fault-edge contract legible; neither changes routing behaviour.

    New lint — flow-error-label-not-fault. type: 'fault' is what routes a failure; label is cosmetic on an ordinary edge. So this, which reads exactly like error handling:

    { source: 'charge_card', target: 'flag_for_review', label: 'error' }

    is an ordinary out-edge — and traverseNext runs every unconditional out-edge in parallel. The handler fires on every successful run of charge_card, concurrently with the real success path, and never on a failure. The run still aborts when the node fails.

    Silent in both directions: the author believes failures are handled, and never notices the handler running when nothing went wrong. The reading is especially natural for an AI author, since the label is precisely what the intent sounds like — which is why this is worth a build-time diagnostic rather than leaving it to a puzzled look at a run trace.

    Deliberately narrow, because a label IS load-bearing on a branching node: a decision / approval executor returns a branchLabel and traversal then prefers the edge carrying it. Edges out of those node types are excluded, as are conditional edges (a guarded path is not the unconditional footgun) and edges already typed fault. Matches the obvious synonyms (error, failure, catch, on_error, …) case-insensitively. Verified against the shipped showcase: no findings.

    An alias — accepting label: 'error' as if it were type: 'fault' — was considered and rejected: two spellings for one concept is harder to read than one spelling plus a diagnostic that names the fix.

    Pinned: a handled failure does not consume a flow-level retry. The two recovery mechanisms have different scopes and must not compound — a fault edge handles one node, while errorHandling.retry replays the flow from the start, re-running every node that already succeeded (a second notification, a second created record). A failure a fault edge handled is not a flow failure, so it does not consume a retry. That already held by construction (a routed failure never propagates out of executeNode); it is now a test, so a refactor of the catch path cannot quietly change it.

    Docs and the automation skill gain both points, plus a note on the edge-property table that label does not select a path except on a branching node.

  • 763931e: feat(filters): evaluate {filter-token} placeholders server-side (#3582)

    Filter values travel as JSON, so a time- or user-scoped slice writes a placeholder instead of code:

    filter: { close_date: { $gte: '{current_year_start}' }, owner: '{current_user_id}' }

    The vocabulary has been in @objectstack/spec for a while (date-macros.zod.ts, context-tokens.zod.ts) and objectstack build rejects tokens outside it (#3574). What was missing is the half that substitutes a value: nothing on the server ever did. A placeholder reached the driver as the literal string '{current_year_start}', compared as text, and matched nothing.

    That failure is invisible — an empty widget looks exactly like a metric that is legitimately zero — so apps worked around it by computing dates at module load, which freezes "this year" into the built artifact and quietly goes stale.

    New: resolveFilterTokens() in @objectstack/core, wired into the two server-side seams every filter passes through:

    • ObjectQL read pathfind / findOne / count / aggregate, so REST queries, related lists, saved-view filters and flow find_records all resolve. It runs before the middleware chain, so only author-supplied filters are inspected; RLS/sharing filters are injected downstream from concrete values.
    • Analytics dataset executor — a dataset's intrinsic filter, a widget's runtimeFilter, measure-scoped filters, and time-dimension dateRanges. This path needs its own call: NativeSQLStrategy compiles raw SQL and binds comparands directly, so a dashboard widget never passes through engine.find().

    Behavioural notes:

    • Date tokens resolve to ISO strings (YYYY-MM-DD, or a full timestamp for {now} / {N_hours_ago} / {N_minutes_ago}). Turning that into a column's on-disk form stays the driver's job (SqlDriver.temporalFilterValue), so there is still exactly one source of truth for the storage convention.
    • Calendar boundaries follow ExecutionContext.timezone; one instant is pinned per filter tree, so a >= {current_month_start} / < {next_month_start} pair can never straddle a boundary.
    • {current_org_id} reads ExecutionContext.tenantId; {current_user_id} reads userId. A request carrying neither now throws instead of resolving to null — a null comparand degrades to IS NULL on most drivers and would hand back the rows the filter was written to exclude.
    • An unrecognised placeholder throws, carrying the near-miss fix ({current_user}{current_user_id}, {this_quarter_start}{current_quarter_start}). This matches what objectstack build already enforces. Consequence, previously implicit and now load-bearing: a filter value that is entirely {...} is always read as a placeholder, so a literal value of that shape is not expressible — rename the value.

    Also in this change: notify no longer sends the six-character string "undefined" as an audience member. to: ['{record.owner.manager}'] walks .manager on a scalar foreign-key id, resolves to nothing, and String(undefined) turned that into a phantom recipient — the emit "succeeded", addressed nobody, and said nothing. Unresolved recipients are now dropped, and a node with no recipient left fails naming the offending template and pointing at the start node's config.expand (#3475), which does hydrate the relation.

  • c88eeda: fix(automation): flow string templates serialize object tokens readably, never [object Object] (#3450)

    A flow string field that embeds an object-valued token — most notably the engine's $error ({nodeId, message, ...}, set on a failed step) in a fault handler's notify body — rendered as the useless [object Object]. The multi-token branch of interpolateString coerced every value with String(), and notify-node did the same for a sole {$error} token.

    • New shared stringifyForTemplate helper (builtin/template.ts): objects and arrays are JSON-serialized (so the text stays legible and still carries the message), primitives pass through, null/undefined render as ''.
    • interpolateString's embedded-substitution branch and notify-node's title/body coercion use it. The sole-token branch still returns the raw value (typed config fields keep their type), and {$error.message} still resolves to just the message string — the documented, cleanest author form.

    Split from #3425 (the readonly-strip half shipped in #3465).

  • 5602211: fix(automation): close the default-routable footgun on refuse-to-execute guards (#3863)

    #3881 stopped a fault edge from swallowing a guard refusal, keyed on NodeExecutionResult.errorClass. That field defaults to 'runtime', which was right for compatibility — every executor written before the split keeps its routing — but it leaves the footgun pointing the other way: a new guard is routable unless its author remembers to classify it, and forgetting is silent. Nothing in the type system catches it.

    Three changes close that for the guards that exist and make the next one hard to get wrong.

    refuseNode(reason) — one call that returns a guard-class failure, so "write a guard" and "mark it un-routable" become the same act. Its doc states the test for using it: re-running unchanged can never succeed AND the fix is to edit metadata. It also states the inverse, because over-marking is not the safe direction — classifying a handleable condition as guard turns a recoverable integration into a dead run.

    Five guards that were never marked are now un-routable. All are missing required config or a defective graph, none can succeed on a retry:

    • http with no url
    • subflow with no config.flowName, and subflow exceeding max nesting depth (a recursive graph nests exactly as deep next run)
    • map with no config.flowName
    • connector_action with no connectorId / actionId

    The seven crud-nodes guards from #3881 move to the helper — same behaviour, one spelling.

    A behavioural inventory test drives every known guard through the engine with a fault edge attached and asserts it is still fatal, matching on the refusal text so a guard failing for a different reason cannot pass vacuously. Verified to have teeth: un-marking one guard fails its row immediately. The negative half is pinned too — a plain node failure and a thrown error must still route, since that is what fault edges are for.

    Deliberately not marked, and why: a degraded connector (#3017 says recovery is automatic), a collection that did not resolve to an array, a collection over the iteration cap, and a subflow that failed on its own. Those are conditions the world caused, and an author must be able to handle them.

    Considered and rejected: making errorClass required on the result type. It would enforce classification at compile time, but it breaks every node executor returning a failure — 281 call sites across the repo plus third-party executors — for a type-only gain over the helper.

  • 9bf4588: fix(service-automation): bind previous (as null) on the create leg so start conditions can discriminate create vs update (#3427)

    The engine bound previous into the flow condition scope only when it was truthy, so on a record insert (record-after-create, and the create leg of record-after-write) previous was an unknown CEL variable. Any reference to it — including the documented previous == null create-discrimination — threw condition failed to evaluate as CEL: Unknown variable: previous, failing the whole start condition and dropping the run.

    previous is now always bound, to null when there is no prior row. So previous == null is the create leg and previous != null / previous.<field> the update leg — the pattern the record-after-write docs and the Studio flow designer advertise. Update-triggered flows are unaffected (previous was, and stays, the prior row there).

  • 70a1ce1: fix(automation): the resume gate follows map: too, and the route stops accepting engine-internal variables (#3853)

    Two holes in the #3801 resume gate, both demonstrated with a repro.

    1. The chain walk missed map:. resumeInternal handles the two linked-run correlations oppositely — a subflow: pause delegates the signal to the child, a map: pause re-runs the map node — and the gate followed only the first. So a run parked on a map node was judged on map itself (resumeAuthority: 'any') and let through even while the item it was waiting on sat on an approval.

    map is the batch-approval shape, and the map parent's run id is the one a launcher holds. Since $mapState.started is advanced past the in-flight item before the suspend, an empty-body resume of the parent skipped that item's approval outright, orphaning its still-pending request; a later real decision then bubbled into a parent already waiting on the next item, cascading the misalignment.

    The walk now follows both prefixes: a linked-run pause is waiting on a CHILD, so the child's node carries the authority — the gate reads the item, not the loop.

    2. Resume inputs could write the engine's $ namespace. They are applied as bare flow variables, so a caller could set the exact handoff keys the engine's map bubble uses (<nodeId>.$mapItemDone / $mapItemOutput) and have the map record a per-item result for a decision nobody made — the node id is readable from GET /automation/:name. The same reached $runId, which approval / wait nodes use to correlate external state back to a run.

    POST /automation/:name/runs/:runId/resume now answers 400 when inputs names anything in the engine namespace ($…, or a .$ segment). Enforced at the transport, not in the engine, so the in-process bubble keeps working — the same trust split the gate itself uses.

    Nothing changes for author-declared variables: { new_assignee: 'ada' } and dotted names like collect.note are unaffected. If you were driving a batch- approval map by resuming the map's own run id, resume the item's run through its owning service instead (e.g. client.approvals.approve) — the map advances itself when the item completes.

  • 93f267f: fix(automation): one chokepoint for the resume signal — output reopened the hole inputs had just closed (#3879)

    #3853 guarded signal.variables at the route. That closed one of two equivalent paths into the same variable map and left the other open: signal.output keys are merged under ${run.nodeId}.${key}, and for a run parked on a map node run.nodeId is the map node — so

    {
      "output": { "$mapItemDone": true, "$mapItemOutput": { "result": "FORGED" } }
    }

    writes exactly the <mapNodeId>.$mapItemDone the inputs guard had refused, making the map record a result for an item nobody decided. Demonstrated with a repro, then fixed.

    Scope: the #3853 map gate still held, so a batch whose pending item sits on an approval was refused before any of this — the approval bypass stayed closed. The residual was forging the recorded result of an item on an ungated pause.

    Two escapes with one shape is a design signal, not two bugs, so the fix is structural rather than a third patch:

    • applyResumeSignal is the one place a resume signal reaches the variable map. Both fields are collected into a single write list (already in final, prefixed form), checked, then applied — a new signal field is covered by construction rather than by remembering.
    • All-or-nothing, and checked before the suspension is consumed: a rejected signal applies nothing (not even legitimate keys sent alongside) and the run stays parked, so the real continuation still lands.
    • The engine owns the rule; the transport maps the verdict. resume returns { success: false, code: 'invalid_signal' }; the route answers 400. The SDK and any future adapter inherit it — implemented in one transport it protected exactly one transport, and one field of it.
    • Engine-built signals (the subflow output mapping, the map item handoff) are exempt via a module-private symbol. Deliberately not RESUME_AUTHORITY_SERVICE: that marker means "the owning service authorized this decision", and a service still has no business writing engine internals.

    AutomationResult.code gains 'invalid_signal' alongside 'forbidden' — a switch over it needs a new arm; a plain read does not.

    Nothing changes for authoring: ordinary variables pass, $ mid-name (price$) and dotted names (collect.note) included. Only names the engine reserves — $… or a .$ segment — are refused.

  • Updated dependencies [50616d9]

  • Updated dependencies [08b5a3d]

  • Updated dependencies [d99aeb3]

  • Updated dependencies [4727eb8]

  • Updated dependencies [f63cd09]

  • Updated dependencies [fa3d0cf]

  • Updated dependencies [af5a224]

  • Updated dependencies [71f76e1]

  • Updated dependencies [37b1346]

  • Updated dependencies [99736a0]

  • Updated dependencies [fe67e34]

  • Updated dependencies [fdb4f50]

  • Updated dependencies [1bd5652]

  • Updated dependencies [14252d3]

  • Updated dependencies [7fb436c]

  • Updated dependencies [879ea13]

  • Updated dependencies [201b31f]

  • Updated dependencies [e2616e0]

  • Updated dependencies [6fdc5c6]

  • Updated dependencies [8b9d71e]

  • Updated dependencies [33f5e23]

  • Updated dependencies [259af21]

  • Updated dependencies [587fc91]

  • Updated dependencies [1986594]

  • Updated dependencies [ad4af62]

  • Updated dependencies [d44dbfa]

  • Updated dependencies [474fe39]

  • Updated dependencies [0bc685a]

  • Updated dependencies [b949059]

  • Updated dependencies [be1c52c]

  • Updated dependencies [c5ff96d]

  • Updated dependencies [84e7be9]

  • Updated dependencies [a6c3f38]

  • Updated dependencies [debc23a]

  • Updated dependencies [0f8ad09]

  • Updated dependencies [8f9689f]

  • Updated dependencies [57a3bb3]

  • Updated dependencies [5f9a987]

  • Updated dependencies [db02d47]

  • Updated dependencies [0bfdf46]

  • Updated dependencies [376a061]

  • Updated dependencies [7c7e246]

  • Updated dependencies [f35cdc5]

  • Updated dependencies [9ea2bc5]

  • Updated dependencies [c2d9098]

  • Updated dependencies [a227ed7]

  • Updated dependencies [9613396]

  • Updated dependencies [e47b342]

  • Updated dependencies [4ed7ed4]

  • Updated dependencies [2fa4ca1]

  • Updated dependencies [f5a2320]

  • Updated dependencies [deb538f]

  • Updated dependencies [5b89711]

  • Updated dependencies [0c8a22f]

  • Updated dependencies [763931e]

  • Updated dependencies [de9af8a]

  • Updated dependencies [c4df271]

  • Updated dependencies [a41ba5c]

  • Updated dependencies [189854c]

  • Updated dependencies [0e3a226]

  • Updated dependencies [1d4756e]

  • Updated dependencies [720c5ad]

  • Updated dependencies [a8d1e24]

  • Updated dependencies [41642b0]

  • Updated dependencies [4cca74c]

  • Updated dependencies [88ef03e]

  • Updated dependencies [9e2caf3]

  • Updated dependencies [81ce41a]

  • Updated dependencies [85e1e4e]

  • Updated dependencies [dac6a08]

  • Updated dependencies [394b7a1]

  • Updated dependencies [677b591]

  • Updated dependencies [d77d1b7]

  • Updated dependencies [5b79a34]

  • Updated dependencies [c757854]

  • Updated dependencies [0045682]

  • Updated dependencies [2a5f04a]

  • Updated dependencies [4f740b0]

  • Updated dependencies [67452d1]

  • Updated dependencies [0fc6219]

  • Updated dependencies [605e190]

  • Updated dependencies [c6c59f1]

  • Updated dependencies [b0e78a8]

  • Updated dependencies [f31cc8d]

  • Updated dependencies [f343dc4]

  • Updated dependencies [8269e32]

  • Updated dependencies [74f7339]

  • Updated dependencies [a6c35a2]

  • Updated dependencies [c2f1002]

  • Updated dependencies [f163028]

  • Updated dependencies [f07808c]

  • Updated dependencies [7ffc3d3]

  • Updated dependencies [88346ba]

  • Updated dependencies [4631592]

  • Updated dependencies [32ff033]

  • Updated dependencies [5ac93d4]

  • Updated dependencies [93f267f]

  • Updated dependencies [0024abf]

  • Updated dependencies [acbf364]

  • Updated dependencies [7687f7b]

  • Updated dependencies [1659072]

  • Updated dependencies [abceb0d]

  • Updated dependencies [0c302a7]

  • Updated dependencies [6633337]

  • Updated dependencies [f00d8d4]

  • Updated dependencies [503be86]

  • Updated dependencies [cde1975]

  • Updated dependencies [0bc685a]

  • Updated dependencies [11949fc]

  • Updated dependencies [b098b0e]

  • Updated dependencies [4d00b13]

  • Updated dependencies [57bab76]

  • Updated dependencies [b90086a]

  • Updated dependencies [b95577a]

  • Updated dependencies [83c161f]

  • Updated dependencies [d8c4957]

  • Updated dependencies [f24cb83]

  • Updated dependencies [5dbbb92]

  • Updated dependencies [69f1dfd]

    • @objectstack/spec@17.0.0-rc.0
    • @objectstack/core@17.0.0-rc.0
    • @objectstack/formula@17.0.0-rc.0

16.1.0

Minor Changes

  • b20201f: fix(service-automation): runAs:'user' runs data ops with the triggering user's real permission sets + positions, not a bare member fallback (#3356, follow-up to #1888)

    Since #1888 the automation engine honours flow.runAs (system elevates), but the runAs:'user' credential propagation was hollow. A record-change-triggered runAs:'user' flow ran its data nodes (update_record, …) with a zero-grant principal — only the member/everyone baseline — even when the triggering user was fully authorized. Two faces by object config: a private object 403'd the in-flow write (not permitted for positions [org_member, everyone] — the user's permission sets were invisible); a public_read_write object let the write through but silently stripped readonly/FLS-gated fields. The root cause: the ObjectQL record-change hook session carries only a userId — never the writer's positions/permission sets — and nothing in between resolved them, so the comment promising "enforces RLS exactly as the user who made the change" never held.

    The fix resolves the triggering user's actual authorization at run setup, from the same tables a direct REST request resolves through:

    • @objectstack/core factors the userId-driven core of resolveAuthzContext into a new exported resolveUserAuthzGrants(ql, userId, opts) — the single place that reads sys_member / sys_user_position / sys_*_permission_set and derives positions, permission-set names, platform_admin, and posture. The HTTP resolver now delegates to it (behaviour byte-identical; the full contract suite still passes), so a non-HTTP surface that already knows the user id builds the SAME envelope instead of re-implementing the reads.
    • @objectstack/service-automation gains AutomationEngine.setUserGrantsResolver, wired by the plugin to resolveUserAuthzGrants over the objectql/data engine. For a runAs:'user' run whose trigger left the authz envelope unresolved (no permissions), the engine now resolves the user's positions + permission sets once at run setup and threads them into every data node's ObjectQL context — so the run enforces RLS/FLS exactly as that user. Contexts that already carry permissions are left untouched (a REST trigger, and notably an ADR-0090 agent ceiling acting on-behalf-of a user — always non-empty — so a deliberately narrowed identity is never re-broadened). runAs:'system' is unchanged, and a resolver error fails safe (warns, keeps the bare user — never elevates).
    • @objectstack/trigger-record-change stops forwarding the misleading half-populated positions (empty in practice, and never permissions) from the hook session; it forwards userId + tenant only and lets the engine resolve the full grants authoritatively.

    When no ObjectQL engine is present (bare engine / tests) the resolver is unwired and run identity is unchanged from before.

Patch Changes

  • Updated dependencies [9e45b63]
  • Updated dependencies [b20201f]
    • @objectstack/spec@16.1.0
    • @objectstack/core@16.1.0
    • @objectstack/formula@16.1.0

16.0.0

Minor Changes

  • 780b4b5: feat(automation): schema-aware flow-condition validation at registration (#1928)

    registerFlow now runs the same schema-aware condition checks as objectstack build — so a flow registered dynamically (via the API / Studio, bypassing the build lint) still gets the guardrail. When the host wires an object-schema resolver, a flow condition that references an unknown field, likely-typos a field name, or does arithmetic/ordering on a text/boolean field against a number is surfaced as an advisory warning (logged), pointing at the object's real schema.

    • New AutomationEngine.setObjectSchemaResolver(resolver) bridge (mirrors setFunctionResolver); AutomationServicePlugin wires it to objectql.registry.getObject in start(), before the flow pull, so registry-sourced flows are covered too.
    • Strictly additive / zero regression: the fatal set is unchanged (syntax, brace-in-CEL, unknown-function still throw); everything the schema pass adds is logged, never thrown, and the whole thing is a no-op when no resolver is wired. Flow conditions bind fields flat, so the check runs in flattened scope (flow variables stay dyn and are never flagged; equality is runtime-safe).

    Builds on the tier-4 type-soundness check in @objectstack/formula / @objectstack/lint (#1928).

  • 2ea08ee: Flow trigger observability — kill the four-layer silence around record-change flows that never fire (2026-07-17 third-party eval).

    A misauthored auto-launched flow (wrong objectName, missing requires: ['automation','triggers'], failing start condition) produced ZERO output at every layer: the engine's own registration/binding logs land inside the CLI's boot-quiet stdout window (which swallows debug/info/warn — only error/fatal reach stderr), and each "didn't happen" path was itself silent. Fixes:

    • Startup banner Flows: section (os serve/os dev/os start): flow count, bound-to-trigger count, registered trigger types, draft count — plus loud lines for flows declared with no automation engine enabled (requires missing), flows whose trigger type has no registered trigger, and bound record-change flows targeting an unknown object (dead binding). Printed after stdout is restored, so it is immune to the boot-quiet window.
    • Trigger-fired run failures now log at ERROR (stderr — always visible): the automation engine no longer drops the AutomationResult of a trigger-fired execution; condition-evaluation faults and node failures surface with the flow name. Condition-not-met skips stay at debug (high-frequency, intentional).
    • RecordChangeTrigger probes object existence at bind time and warns when a flow's objectName matches no registered object (exact-name matching), instead of silently arming a hook that can never fire.
    • kernel:bootstrapped binding audit in the automation plugin: warns per enabled-but-unbound triggered flow with the reason, and reports registered/bound/draft counts (AutomationEngine.getTriggerBindingAudit(), extended getFlowRuntimeStates() with status/triggerType/object).
    • os validate flow-wiring advisories (@objectstack/lint validateFlowTriggerReadiness): warns when a record-triggered flow targets an object the stack does not define, and when an auto-triggered flow's status is draft (authored or defaulted — draft flows still fire; declare active or obsolete).
    • Removed leftover boot-debug writes (registerApp/AppPlugin/StandaloneStack/AuditPlugin stderr noise) that previous debugging of this same silence had left behind.
  • 1e145eb: fix(automation): region-aware run-history compaction keeps loop containers + early failures (#3234)

    compactStepsForHistory bounded a terminal run's persisted step log to the last MAX_PERSISTED_HISTORY_STEPS entries with a plain tail-slice. With the ADR-0031 structured-region step logs (#1505) a single loop can emit iterations × body-steps entries, so the tail-slice dropped the loop/parallel/try_catch container step (it precedes all its body steps) and every early iteration — leaving getRun/listRuns (after a process restart or ring-buffer eviction) with body steps the Runs surface could no longer nest, and silently hiding an early failure.

    Compaction is now region-aware (new exported compactStepLogForHistory): over budget it keeps the run's structural backbone — every top-level step (including the region container steps) and every failure, each pulled in with its ancestor container chain — plus the most recent body steps, order-preserving and hard-capped at max so steps_json stays bounded (#2585). Every retained body step keeps its enclosing container(s), so the compacted log never contains an orphan and the observability surface's per-iteration / per-region nesting still reconstructs.

  • a2795f6: feat(triggers): declarative time-relative trigger — daily sweep instead of fragile date-equality (#1874)

    Time-relative business rules ("alert 60 days before a contract's end_date") could only be expressed as a record_change flow gated on a date-equality condition like end_date == daysFromNow(60). That predicate is only evaluated when the record happens to change, so it fires only if a record is edited on exactly the threshold day — i.e. almost never, unattended. The robust alternative was a hand-written cron + range query that every author re-implemented (contracts renewal_alert, hr document_expiring_soon, procurement po_overdue, …).

    A flow's start node can now declare a timeRelative descriptor instead:

    config: {
      timeRelative: {
        object: 'contracts',
        dateField: 'end_date',
        offsetDays: [60, 30, 7],      // T-minus reminders — fires on each threshold day
        // — or — withinDays: 30      // "expiring soon" range; negative = overdue lookback
        filter: { status: 'active' }, // optional, ANDed with the date window
      },
      schedule: { type: 'cron', expression: '0 8 * * *' }, // optional; defaults to daily 08:00 UTC
    }

    The new time_relative trigger (shipped in @objectstack/trigger-schedule as TimeRelativeTriggerPlugin) sweeps the object on that schedule and launches the flow once per matching record, with the record on the automation context — so the start-node condition gate and {record.<field>} interpolation work exactly as for a record-change flow. Because the window is evaluated every day, a threshold is never missed regardless of when the record last changed. The discovery query runs as a system operation (RLS-bypassing) and is capped (maxRecords, default 1000) so a mis-scoped window can't fan out unboundedly; per-record failures are isolated so one bad row never aborts the sweep.

    The automation engine routes a start node carrying config.timeRelative to the time_relative trigger (ahead of the plain schedule trigger, whose behavior is unchanged), and os validate gains readiness checks for the new descriptor (unknown swept object, ambiguous draft status). New authorable spec key: TimeRelativeTriggerSchema (@objectstack/spec/automation).

Patch Changes

  • 22013aa: Split the overloaded managedBy: 'system' bucket into engine-owned vs. admin-writable, and enforce engine-owned writes (ADR-0103, #3220). The system bucket conflated two incompatible write policies: rows a platform service owns end to end (never user-written), and platform-defined schema whose rows are legitimately admin/user-writable. It carried the same all-false affordance row as better-auth/append-only but, unlike better-auth, had no engine enforcement — a wildcard admin could raw-write these rows through the generic data API (ADR-0049 gap).

    Rather than add a new managedBy enum value (which would fall through to fully-editable platform defaults on already-deployed Console clients), the write policy is now the resolved affordance (resolveCrudAffordances = bucket default + userActions), and engine-owned is defined as a system/append-only object that grants no write:

    • Writable set declares userActions — the RBAC link tables (sys_user_position, sys_user_permission_set, sys_position_permission_set), sys_user_preference, sys_approval_delegation, and the messaging config grids (sys_notification_preference / …_subscription / …_template) now declare userActions: { create, edit, delete: true }. The affordance is a declaration only — the DelegatedAdminGate / RLS / permission sets remain the authz.
    • Engine-owned objects locked to readsapiMethods: ['get','list'] added where absent (jobs, notifications, approval request/approver/token/action, sys_record_share, sys_automation_run, mail/settings/secret audit, the messaging delivery pipeline). sys_secret is explicitly read-locked (an empty apiMethods array fails open).
    • sys_import_job stays engine-owned: the REST import route now writes its job rows isSystem-elevated (attribution preserved via the explicit created_by stamp) and the object is locked to ['get','list'].
    • New engine write guard (assertEngineOwnedWriteAllowed, plugin-security) fail-closed rejects user-context generic writes to engine-owned system/append-only objects, keyed off the resolved affordance; isSystem and context-less engine/service writes bypass by construction. Wired into the security middleware alongside the other data-layer gates.
    • reconcileManagedApiMethods (objectql registry) now runs for every managed bucket, not just better-auth: any advertised write verb an object's resolved affordances forbid is stripped at registration with a warning (the drift backstop, ADR-0049).
    • /me/permissions clamp (plugin-hono-server) now clamps system/append-only as well as better-auth, so the client hint reflects permission ∩ guard.

    Potentially breaking: a downstream/third-party system object that advertised generic write verbs relying on today's fail-open behaviour will have those verbs stripped (with a warning) and user-context generic writes to it rejected. Declare userActions opening the verbs the object legitimately takes from a user context. better-auth keeps plugin-auth's identity write guard unchanged; the row-level managed_by provenance vocabulary (ADR-0066) is a different axis and is untouched.

  • 02eafa5: test(automation): end-to-end coverage for the #1928 object-schema resolver wiring

    Adds a kernel-level integration test proving AutomationServicePlugin bridges the engine's object-schema resolver to the live objectql.registry.getObject at start() (fields + types resolved from the registry), and that a flow registered through the running kernel with a text field misused in arithmetic emits the tier-4 advisory — while a sound condition stays quiet. Locks in the production integration point that the engine-level unit tests (which set the resolver by hand) could not exercise. Test-only; no behavior change.

  • b320158: feat(automation): publish configSchemas for the keyValue-capable nodes (flow designer parity, #3304)

    The assignment, create_record / update_record / delete_record / get_record, and screen nodes shipped no configSchema, so the flow designer had no server-driven form for them. Each descriptor now carries one that mirrors the objectui hardcoded field group field-for-field: object references as xRef, the screen repeater's visibleWhen as xExpression: 'expression', and the free-form maps (fields / filter / assignments / defaults) as JSON-Schema open objects (additionalProperties: true, no fixed properties) — the shape the designer's schema adapter renders with its flat keyValue editor. Values stay fully permissive because real metadata carries operator objects ({"$ne": null}), {var} templates, and non-string literals.

    Deliberately still schemaless (no online/offline divergence exists for a node with no configSchema, and a partial schema would drop editors): decision (virtual Target column derived from edges), wait (top-level waitEventConfig), script (actionType-conditional form), subflow (top-level timeoutMs).

    Additive and backward-compatible: descriptor metadata only, no runtime behavior change. Requires an objectui with the keyValue schema mapping (objectui #2708) for the maps to render as structured editors; older designers keep their hardcoded forms.

  • 158aa14: feat(automation): mark the loop collection config field as an interpolate() template so designer forms render it correctly (#3304)

    The flow designer generates a node's config form from its published configSchema (ADR-0018). A string property can now carry an xExpression: 'expression' | 'template' marker — riding the same Zod .meta() → JSON-Schema channel as xRef / xEnumDeprecated — that declares whether the string is bare CEL or an interpolate() single-brace {var} template.

    The loop node's collection (e.g. {tasks}) is a template, so it is now marked xExpression: 'template' on both the canonical LoopConfigSchema and the shipped descriptor's configSchema literal (service-automation loop-node). Without the marker the designer rendered collection as plain text online while the offline hardcoded form rendered it as a mono expression editor, and the CEL brace-trap false-flagged {tasks} as a malformed condition. The marker closes that divergence — objectui #2670 Phase 3 (#2699) already consumes it.

    Additive and backward-compatible: an unknown xExpression value is ignored by the designer, and runtime behavior is unchanged. Filling the same marker in on the remaining node types (map/decision/script and the node types that publish no configSchema yet) is tracked as follow-up in #3304.

  • 62a2117: Split the overloaded managedBy: 'system' bucket with an explicit engine-owned value (ADR-0103 addendum, #3343). ADR-0103 deferred the enum split ("revisitable later as a rename") because a new managedBy value would fall through to the fully-editable platform default on deployed Console clients. Both reasons against it are now retired — the server-side write guard / apiMethods reconciliation / /me/permissions clamp make that fallthrough cosmetic (the write is rejected regardless of what the client renders), and objectui#2712 closed the UI union — so v16 lands it, additively.

    • New enum value engine-owned with the same all-locked default affordance row as system (create/import/edit/delete: false, exportCsv: true). It joins ENGINE_OWNED_BUCKETS (the engine write guard) and GUARDED_WRITE_BUCKETS (the /me/permissions clamp); the guard, reconcileManagedApiMethods, and the clamp mechanisms are unchanged — engine-owned is an explicit member of the set they already covered by resolved affordance.
    • 20 objects relabelled system → engine-owned — the ones the engine owns end to end and that declared no write-opening userActions (the metadata store, jobs, approval runtime rows, sharing rows, sys_automation_run, the messaging delivery/receipt pipeline, sys_secret, settings). One-line, behaviour-identical per object.
    • 8 admin/user-writable objects keep managedBy: 'system' (the RBAC link tables, sys_user_preference, sys_approval_delegation, the messaging config grids) — system now reads as "engine-managed schema, writable via userActions".

    Behaviour-, enforcement- and wire-identical: resolved affordances, the guard verdict, the 405 apiMethods reconciliation, and the permissions clamp are the same before and after — this is a self-documenting relabel, not a policy change. No data migration (managedBy is schema metadata) and no code branches on the 'system' literal. Retiring the overloaded system entirely (moving the 8 writable objects to a dedicated bucket) is a breaking rename deferred to v17.

  • f8c1b69: feat(automation): publish a configSchema for the map node (flow designer parity, #3304)

    The map (sequential multi-instance) node shipped no configSchema, so the flow designer fell back to its hardcoded field group online and to raw Advanced-JSON where that wasn't present. Its descriptor now carries a structured configSchema that mirrors the objectui hardcoded map field group field-for-field — collection (marked xExpression: 'template', an interpolate() {items} template, same as loop.collection), flowName + itemObject as typed references (xRef), and iteratorVariable / outputVariable as plain text — so the online (schema-driven) and offline forms match.

    map is the one previously-schemaless flow node whose fields are all scalars and typed references, so it maps cleanly through objectui's jsonSchemaToFlowFields with zero regression. The remaining schemaless nodes lean on editor kinds the schema→fields adapter does not yet reproduce (keyValue maps, the decision virtual target column, wait's top-level block), and are deferred to #3304 until that adapter is extended. Additive and backward-compatible: no runtime behavior change; an older designer that ignores the schema is unaffected.

  • Updated dependencies [f972574]

  • Updated dependencies [6289ec3]

  • Updated dependencies [22013aa]

  • Updated dependencies [3ad3dd5]

  • Updated dependencies [8efa395]

  • Updated dependencies [3a18b60]

  • Updated dependencies [a8aa34c]

  • Updated dependencies [e057f42]

  • Updated dependencies [a3823b2]

  • Updated dependencies [43a3efb]

  • Updated dependencies [524696a]

  • Updated dependencies [6b51346]

  • Updated dependencies [80273c8]

  • Updated dependencies [bfa3c3f]

  • Updated dependencies [5e3301d]

  • Updated dependencies [dd9f223]

  • Updated dependencies [46e876c]

  • Updated dependencies [7125007]

  • Updated dependencies [5f05de2]

  • Updated dependencies [021ba4c]

  • Updated dependencies [158aa14]

  • Updated dependencies [62a2117]

  • Updated dependencies [d2723e2]

  • Updated dependencies [fefcd54]

  • Updated dependencies [beaf2de]

  • Updated dependencies [369eb6e]

  • Updated dependencies [06ff734]

  • Updated dependencies [b659111]

  • Updated dependencies [5754a23]

  • Updated dependencies [6c270a6]

  • Updated dependencies [290e2f0]

  • Updated dependencies [668dd17]

  • Updated dependencies [8abf133]

  • Updated dependencies [e0859b1]

  • Updated dependencies [04ecd4e]

  • Updated dependencies [4d5a892]

  • Updated dependencies [16cebeb]

  • Updated dependencies [86d30af]

  • Updated dependencies [8923843]

  • Updated dependencies [ea32ec7]

  • Updated dependencies [a2795f6]

  • Updated dependencies [f16b492]

  • Updated dependencies [4b6fde8]

  • Updated dependencies [2018df9]

  • Updated dependencies [fc5a3a2]

  • Updated dependencies [8ff9210]

    • @objectstack/spec@16.0.0
    • @objectstack/core@16.0.0
    • @objectstack/formula@16.0.0

16.0.0-rc.1

Patch Changes

  • b320158: feat(automation): publish configSchemas for the keyValue-capable nodes (flow designer parity, #3304)

    The assignment, create_record / update_record / delete_record / get_record, and screen nodes shipped no configSchema, so the flow designer had no server-driven form for them. Each descriptor now carries one that mirrors the objectui hardcoded field group field-for-field: object references as xRef, the screen repeater's visibleWhen as xExpression: 'expression', and the free-form maps (fields / filter / assignments / defaults) as JSON-Schema open objects (additionalProperties: true, no fixed properties) — the shape the designer's schema adapter renders with its flat keyValue editor. Values stay fully permissive because real metadata carries operator objects ({"$ne": null}), {var} templates, and non-string literals.

    Deliberately still schemaless (no online/offline divergence exists for a node with no configSchema, and a partial schema would drop editors): decision (virtual Target column derived from edges), wait (top-level waitEventConfig), script (actionType-conditional form), subflow (top-level timeoutMs).

    Additive and backward-compatible: descriptor metadata only, no runtime behavior change. Requires an objectui with the keyValue schema mapping (objectui #2708) for the maps to render as structured editors; older designers keep their hardcoded forms.

  • 62a2117: Split the overloaded managedBy: 'system' bucket with an explicit engine-owned value (ADR-0103 addendum, #3343). ADR-0103 deferred the enum split ("revisitable later as a rename") because a new managedBy value would fall through to the fully-editable platform default on deployed Console clients. Both reasons against it are now retired — the server-side write guard / apiMethods reconciliation / /me/permissions clamp make that fallthrough cosmetic (the write is rejected regardless of what the client renders), and objectui#2712 closed the UI union — so v16 lands it, additively.

    • New enum value engine-owned with the same all-locked default affordance row as system (create/import/edit/delete: false, exportCsv: true). It joins ENGINE_OWNED_BUCKETS (the engine write guard) and GUARDED_WRITE_BUCKETS (the /me/permissions clamp); the guard, reconcileManagedApiMethods, and the clamp mechanisms are unchanged — engine-owned is an explicit member of the set they already covered by resolved affordance.
    • 20 objects relabelled system → engine-owned — the ones the engine owns end to end and that declared no write-opening userActions (the metadata store, jobs, approval runtime rows, sharing rows, sys_automation_run, the messaging delivery/receipt pipeline, sys_secret, settings). One-line, behaviour-identical per object.
    • 8 admin/user-writable objects keep managedBy: 'system' (the RBAC link tables, sys_user_preference, sys_approval_delegation, the messaging config grids) — system now reads as "engine-managed schema, writable via userActions".

    Behaviour-, enforcement- and wire-identical: resolved affordances, the guard verdict, the 405 apiMethods reconciliation, and the permissions clamp are the same before and after — this is a self-documenting relabel, not a policy change. No data migration (managedBy is schema metadata) and no code branches on the 'system' literal. Retiring the overloaded system entirely (moving the 8 writable objects to a dedicated bucket) is a breaking rename deferred to v17.

  • f8c1b69: feat(automation): publish a configSchema for the map node (flow designer parity, #3304)

    The map (sequential multi-instance) node shipped no configSchema, so the flow designer fell back to its hardcoded field group online and to raw Advanced-JSON where that wasn't present. Its descriptor now carries a structured configSchema that mirrors the objectui hardcoded map field group field-for-field — collection (marked xExpression: 'template', an interpolate() {items} template, same as loop.collection), flowName + itemObject as typed references (xRef), and iteratorVariable / outputVariable as plain text — so the online (schema-driven) and offline forms match.

    map is the one previously-schemaless flow node whose fields are all scalars and typed references, so it maps cleanly through objectui's jsonSchemaToFlowFields with zero regression. The remaining schemaless nodes lean on editor kinds the schema→fields adapter does not yet reproduce (keyValue maps, the decision virtual target column, wait's top-level block), and are deferred to #3304 until that adapter is extended. Additive and backward-compatible: no runtime behavior change; an older designer that ignores the schema is unaffected.

  • Updated dependencies [6289ec3]

  • Updated dependencies [8efa395]

  • Updated dependencies [bfa3c3f]

  • Updated dependencies [7125007]

  • Updated dependencies [62a2117]

  • Updated dependencies [06ff734]

    • @objectstack/spec@16.0.0-rc.1
    • @objectstack/formula@16.0.0-rc.1
    • @objectstack/core@16.0.0-rc.1

16.0.0-rc.0

Minor Changes

  • 780b4b5: feat(automation): schema-aware flow-condition validation at registration (#1928)

    registerFlow now runs the same schema-aware condition checks as objectstack build — so a flow registered dynamically (via the API / Studio, bypassing the build lint) still gets the guardrail. When the host wires an object-schema resolver, a flow condition that references an unknown field, likely-typos a field name, or does arithmetic/ordering on a text/boolean field against a number is surfaced as an advisory warning (logged), pointing at the object's real schema.

    • New AutomationEngine.setObjectSchemaResolver(resolver) bridge (mirrors setFunctionResolver); AutomationServicePlugin wires it to objectql.registry.getObject in start(), before the flow pull, so registry-sourced flows are covered too.
    • Strictly additive / zero regression: the fatal set is unchanged (syntax, brace-in-CEL, unknown-function still throw); everything the schema pass adds is logged, never thrown, and the whole thing is a no-op when no resolver is wired. Flow conditions bind fields flat, so the check runs in flattened scope (flow variables stay dyn and are never flagged; equality is runtime-safe).

    Builds on the tier-4 type-soundness check in @objectstack/formula / @objectstack/lint (#1928).

  • 2ea08ee: Flow trigger observability — kill the four-layer silence around record-change flows that never fire (2026-07-17 third-party eval).

    A misauthored auto-launched flow (wrong objectName, missing requires: ['automation','triggers'], failing start condition) produced ZERO output at every layer: the engine's own registration/binding logs land inside the CLI's boot-quiet stdout window (which swallows debug/info/warn — only error/fatal reach stderr), and each "didn't happen" path was itself silent. Fixes:

    • Startup banner Flows: section (os serve/os dev/os start): flow count, bound-to-trigger count, registered trigger types, draft count — plus loud lines for flows declared with no automation engine enabled (requires missing), flows whose trigger type has no registered trigger, and bound record-change flows targeting an unknown object (dead binding). Printed after stdout is restored, so it is immune to the boot-quiet window.
    • Trigger-fired run failures now log at ERROR (stderr — always visible): the automation engine no longer drops the AutomationResult of a trigger-fired execution; condition-evaluation faults and node failures surface with the flow name. Condition-not-met skips stay at debug (high-frequency, intentional).
    • RecordChangeTrigger probes object existence at bind time and warns when a flow's objectName matches no registered object (exact-name matching), instead of silently arming a hook that can never fire.
    • kernel:bootstrapped binding audit in the automation plugin: warns per enabled-but-unbound triggered flow with the reason, and reports registered/bound/draft counts (AutomationEngine.getTriggerBindingAudit(), extended getFlowRuntimeStates() with status/triggerType/object).
    • os validate flow-wiring advisories (@objectstack/lint validateFlowTriggerReadiness): warns when a record-triggered flow targets an object the stack does not define, and when an auto-triggered flow's status is draft (authored or defaulted — draft flows still fire; declare active or obsolete).
    • Removed leftover boot-debug writes (registerApp/AppPlugin/StandaloneStack/AuditPlugin stderr noise) that previous debugging of this same silence had left behind.
  • 1e145eb: fix(automation): region-aware run-history compaction keeps loop containers + early failures (#3234)

    compactStepsForHistory bounded a terminal run's persisted step log to the last MAX_PERSISTED_HISTORY_STEPS entries with a plain tail-slice. With the ADR-0031 structured-region step logs (#1505) a single loop can emit iterations × body-steps entries, so the tail-slice dropped the loop/parallel/try_catch container step (it precedes all its body steps) and every early iteration — leaving getRun/listRuns (after a process restart or ring-buffer eviction) with body steps the Runs surface could no longer nest, and silently hiding an early failure.

    Compaction is now region-aware (new exported compactStepLogForHistory): over budget it keeps the run's structural backbone — every top-level step (including the region container steps) and every failure, each pulled in with its ancestor container chain — plus the most recent body steps, order-preserving and hard-capped at max so steps_json stays bounded (#2585). Every retained body step keeps its enclosing container(s), so the compacted log never contains an orphan and the observability surface's per-iteration / per-region nesting still reconstructs.

  • a2795f6: feat(triggers): declarative time-relative trigger — daily sweep instead of fragile date-equality (#1874)

    Time-relative business rules ("alert 60 days before a contract's end_date") could only be expressed as a record_change flow gated on a date-equality condition like end_date == daysFromNow(60). That predicate is only evaluated when the record happens to change, so it fires only if a record is edited on exactly the threshold day — i.e. almost never, unattended. The robust alternative was a hand-written cron + range query that every author re-implemented (contracts renewal_alert, hr document_expiring_soon, procurement po_overdue, …).

    A flow's start node can now declare a timeRelative descriptor instead:

    config: {
      timeRelative: {
        object: 'contracts',
        dateField: 'end_date',
        offsetDays: [60, 30, 7],      // T-minus reminders — fires on each threshold day
        // — or — withinDays: 30      // "expiring soon" range; negative = overdue lookback
        filter: { status: 'active' }, // optional, ANDed with the date window
      },
      schedule: { type: 'cron', expression: '0 8 * * *' }, // optional; defaults to daily 08:00 UTC
    }

    The new time_relative trigger (shipped in @objectstack/trigger-schedule as TimeRelativeTriggerPlugin) sweeps the object on that schedule and launches the flow once per matching record, with the record on the automation context — so the start-node condition gate and {record.<field>} interpolation work exactly as for a record-change flow. Because the window is evaluated every day, a threshold is never missed regardless of when the record last changed. The discovery query runs as a system operation (RLS-bypassing) and is capped (maxRecords, default 1000) so a mis-scoped window can't fan out unboundedly; per-record failures are isolated so one bad row never aborts the sweep.

    The automation engine routes a start node carrying config.timeRelative to the time_relative trigger (ahead of the plain schedule trigger, whose behavior is unchanged), and os validate gains readiness checks for the new descriptor (unknown swept object, ambiguous draft status). New authorable spec key: TimeRelativeTriggerSchema (@objectstack/spec/automation).

Patch Changes

  • 22013aa: Split the overloaded managedBy: 'system' bucket into engine-owned vs. admin-writable, and enforce engine-owned writes (ADR-0103, #3220). The system bucket conflated two incompatible write policies: rows a platform service owns end to end (never user-written), and platform-defined schema whose rows are legitimately admin/user-writable. It carried the same all-false affordance row as better-auth/append-only but, unlike better-auth, had no engine enforcement — a wildcard admin could raw-write these rows through the generic data API (ADR-0049 gap).

    Rather than add a new managedBy enum value (which would fall through to fully-editable platform defaults on already-deployed Console clients), the write policy is now the resolved affordance (resolveCrudAffordances = bucket default + userActions), and engine-owned is defined as a system/append-only object that grants no write:

    • Writable set declares userActions — the RBAC link tables (sys_user_position, sys_user_permission_set, sys_position_permission_set), sys_user_preference, sys_approval_delegation, and the messaging config grids (sys_notification_preference / …_subscription / …_template) now declare userActions: { create, edit, delete: true }. The affordance is a declaration only — the DelegatedAdminGate / RLS / permission sets remain the authz.
    • Engine-owned objects locked to readsapiMethods: ['get','list'] added where absent (jobs, notifications, approval request/approver/token/action, sys_record_share, sys_automation_run, mail/settings/secret audit, the messaging delivery pipeline). sys_secret is explicitly read-locked (an empty apiMethods array fails open).
    • sys_import_job stays engine-owned: the REST import route now writes its job rows isSystem-elevated (attribution preserved via the explicit created_by stamp) and the object is locked to ['get','list'].
    • New engine write guard (assertEngineOwnedWriteAllowed, plugin-security) fail-closed rejects user-context generic writes to engine-owned system/append-only objects, keyed off the resolved affordance; isSystem and context-less engine/service writes bypass by construction. Wired into the security middleware alongside the other data-layer gates.
    • reconcileManagedApiMethods (objectql registry) now runs for every managed bucket, not just better-auth: any advertised write verb an object's resolved affordances forbid is stripped at registration with a warning (the drift backstop, ADR-0049).
    • /me/permissions clamp (plugin-hono-server) now clamps system/append-only as well as better-auth, so the client hint reflects permission ∩ guard.

    Potentially breaking: a downstream/third-party system object that advertised generic write verbs relying on today's fail-open behaviour will have those verbs stripped (with a warning) and user-context generic writes to it rejected. Declare userActions opening the verbs the object legitimately takes from a user context. better-auth keeps plugin-auth's identity write guard unchanged; the row-level managed_by provenance vocabulary (ADR-0066) is a different axis and is untouched.

  • 02eafa5: test(automation): end-to-end coverage for the #1928 object-schema resolver wiring

    Adds a kernel-level integration test proving AutomationServicePlugin bridges the engine's object-schema resolver to the live objectql.registry.getObject at start() (fields + types resolved from the registry), and that a flow registered through the running kernel with a text field misused in arithmetic emits the tier-4 advisory — while a sound condition stays quiet. Locks in the production integration point that the engine-level unit tests (which set the resolver by hand) could not exercise. Test-only; no behavior change.

  • 158aa14: feat(automation): mark the loop collection config field as an interpolate() template so designer forms render it correctly (#3304)

    The flow designer generates a node's config form from its published configSchema (ADR-0018). A string property can now carry an xExpression: 'expression' | 'template' marker — riding the same Zod .meta() → JSON-Schema channel as xRef / xEnumDeprecated — that declares whether the string is bare CEL or an interpolate() single-brace {var} template.

    The loop node's collection (e.g. {tasks}) is a template, so it is now marked xExpression: 'template' on both the canonical LoopConfigSchema and the shipped descriptor's configSchema literal (service-automation loop-node). Without the marker the designer rendered collection as plain text online while the offline hardcoded form rendered it as a mono expression editor, and the CEL brace-trap false-flagged {tasks} as a malformed condition. The marker closes that divergence — objectui #2670 Phase 3 (#2699) already consumes it.

    Additive and backward-compatible: an unknown xExpression value is ignored by the designer, and runtime behavior is unchanged. Filling the same marker in on the remaining node types (map/decision/script and the node types that publish no configSchema yet) is tracked as follow-up in #3304.

  • Updated dependencies [f972574]

  • Updated dependencies [22013aa]

  • Updated dependencies [3ad3dd5]

  • Updated dependencies [3a18b60]

  • Updated dependencies [a8aa34c]

  • Updated dependencies [e057f42]

  • Updated dependencies [a3823b2]

  • Updated dependencies [43a3efb]

  • Updated dependencies [524696a]

  • Updated dependencies [6b51346]

  • Updated dependencies [80273c8]

  • Updated dependencies [5e3301d]

  • Updated dependencies [dd9f223]

  • Updated dependencies [46e876c]

  • Updated dependencies [5f05de2]

  • Updated dependencies [021ba4c]

  • Updated dependencies [158aa14]

  • Updated dependencies [d2723e2]

  • Updated dependencies [fefcd54]

  • Updated dependencies [beaf2de]

  • Updated dependencies [369eb6e]

  • Updated dependencies [b659111]

  • Updated dependencies [5754a23]

  • Updated dependencies [6c270a6]

  • Updated dependencies [290e2f0]

  • Updated dependencies [668dd17]

  • Updated dependencies [8abf133]

  • Updated dependencies [e0859b1]

  • Updated dependencies [04ecd4e]

  • Updated dependencies [4d5a892]

  • Updated dependencies [16cebeb]

  • Updated dependencies [86d30af]

  • Updated dependencies [8923843]

  • Updated dependencies [ea32ec7]

  • Updated dependencies [a2795f6]

  • Updated dependencies [f16b492]

  • Updated dependencies [4b6fde8]

  • Updated dependencies [2018df9]

  • Updated dependencies [fc5a3a2]

    • @objectstack/spec@16.0.0-rc.0
    • @objectstack/core@16.0.0-rc.0
    • @objectstack/formula@16.0.0-rc.0

15.1.1

Patch Changes

  • @objectstack/spec@15.1.1
  • @objectstack/core@15.1.1
  • @objectstack/formula@15.1.1

15.1.0

Minor Changes

  • f531a26: feat(connectors): ADR-0096 — provider-bound declarative connector instances materialized at boot (#2977)

    Declarative connectors: stack entries used to be descriptor-only (#2612): registered as metadata but never dispatchable, the platform's one dead metadata surface. An entry may now name a provider — an installed generic executor (openapi / mcp / rest) — and the automation service materializes it into a live, dispatchable connector at boot. AI can now wire an integration as pure metadata and a flow connector_action calls it end-to-end.

    • Schema (@objectstack/spec). ConnectorSchema gains provider, providerConfig, and auth (a credentialRef-based instance-auth shape — ConnectorInstanceAuthSchema — that references credentials, never inlines them); authentication now defaults to { type: 'none' } so a provider-bound instance need not author it (loosening — existing connectors are unaffected). DeclarativeConnectorEntrySchema (used by stack.zod.ts) rejects inline secrets, orphan providerConfig/auth, and authored actions/triggers on a provider-bound entry. A new integration/connector-provider.ts defines the provider-factory contract as pure types.

    • Engine + boot (@objectstack/service-automation). The engine adds a connector-provider registry (registerConnectorProvider/getConnectorProvider) and origin-tags registered connectors. At boot the service resolves each provider-bound entry — looking up the factory, resolving auth.credentialRef via a pluggable CredentialResolver (open-tier default: environment variables), and registering the materialized connector. Boot fails loudly for an unknown provider, invalid providerConfig, an unresolvable credentialRef, or a name conflict with a plugin-registered connector (no silent precedence).

    • Providers (connector-rest / connector-openapi / connector-mcp). Each plugin registers a provider factory in init() reusing its existing generator/adapter API. Plugin options are now optional: with none the plugin contributes only its provider factory; with instance options it also registers a hand-wired connector (back-compat). connector-openapi adds a ConnectorOpenApiPlugin.

    Open tier: static auth (none/api-key/basic/bearer) with credentialRef resolved from env vars. Managed vaulting, OAuth2 refresh, and per-tenant connection lifecycle remain the enterprise tier (ADR-0015) — an enterprise host injects a vault-backed CredentialResolver with no change to the materialization path.

  • f531a26: feat(connector-openapi): resolve providerConfig.spec from a package-relative file path (#3016, ADR-0096 follow-up)

    ADR-0096's canonical example authors an OpenAPI-backed instance as providerConfig: { spec: './billing-openapi.json' }, but the landed openapi provider factory only accepted an inline document object or an http(s) URL. The spec union is now complete: inline object | file path | remote URL.

    • @objectstack/spec. ConnectorProviderContext gains an optional host-injected loadPackageFile(relativePath) capability (pure type): reads a UTF-8 file resolved against the declaring stack/package root, confined to that root. undefined on hosts without a filesystem.

    • @objectstack/service-automation. New packageRoot plugin option (the base for relative file refs; defaults to process.cwd()) and an exported createPackageFileLoader(packageRoot) that implements the confinement guard — absolute paths and ..-escaping paths are rejected — with lazy node:fs/node:path imports so non-Node hosts only fail if a file ref is actually dereferenced. The materializer injects the capability into every provider factory's context. Failures follow the existing reconcile policy: fatal at boot, entry skipped on reload.

    • @objectstack/connector-openapi. A string providerConfig.spec that is not an http(s) URL is now read via ctx.loadPackageFile and parsed as an OpenAPI JSON document (clear errors for missing/unreadable files, unparseable JSON, and hosts without package file access).

    • @objectstack/cli. serve/dev pass the project folder (the objectstack.config.ts directory) as the automation service's packageRoot, mirroring how the standalone sqlite default is anchored.

  • f531a26: feat(connectors): ADR-0096 runtime re-materialization of declarative connectors (#2977 follow-up)

    Provider-bound declarative connectors: instances (ADR-0096) previously materialized only at boot — a connector published from Studio while the server ran did not become dispatchable until a restart. materializeDeclaredConnectors is now a reconcile run both at boot and on metadata:reloaded:

    • Add newly-declared instances, tear down removed / newly-enabled:false ones (calling their close, e.g. an MCP connection), and re-materialize only instances whose signature — a stable hash of provider + providerConfig
      • auth + identity — changed. An unchanged MCP instance is never needlessly reconnected on an unrelated metadata reload.
    • Boot stays fatal ("fail loudly"): unknown provider / invalid providerConfig / unresolvable credentialRef / name conflict aborts startup. Reload is soft: the same problems are logged and the offending entry skipped, so a bad publish never crashes a running server; a changed instance's old connector keeps serving until its replacement materializes successfully.

    Also: ConnectorDescriptor (served by GET /api/v1/automation/connectors) now carries an origin field ('plugin' | 'declarative'), so a designer can distinguish a materialized declarative instance from a plugin-registered connector.

  • f531a26: feat(connectors): degrade + retry declarative instances whose upstream is unreachable (#3017)

    ADR-0097 kept every declarative-connector materialization failure fatal at boot. That is right for configuration faults (unknown provider, invalid providerConfig, unresolvable credentialRef, name conflict) but wrong for operational ones: a provider: 'mcp' instance must contact its MCP server (tools/list) to materialize, and a transient network blip aborted the whole app boot.

    • spec: a provider factory can now throw ConnectorUpstreamUnavailableError (code CONNECTOR_UPSTREAM_UNAVAILABLE, structural guard isConnectorUpstreamUnavailable) to mark a failure as "upstream temporarily unreachable — degrade and retry" instead of fatal.
    • service-automation: the reconcile degrades such an instance in both boot and reload modes: it registers an action-less husk (state: 'degraded' + degradedReason on the GET /connectors descriptor) so the instance is visible instead of silently missing — or, on a changed-config re-materialization, keeps the old connector serving. A connector_action against a degraded instance fails with the reason and a "retries automatically" pointer. Degraded instances retry on an exponential backoff (5s → 5min, reset by config edits) and on every metadata:reloaded reconcile; recovery swaps the husk for the live connector atomically. Reconcile runs (boot / reload / retry timer) are now serialized.
    • connector-mcp: the mcp provider classifies connect / tools/list failures as upstream-unavailable; transport-shape validation stays a plain (fatal) throw.

    Configuration faults remain loud boot failures — the carve-out is only for the unavailable marker.

  • f531a26: feat(automation): descriptor-only contract + boot audit for declarative connectors: (#2612)

    Declarative connectors: stack entries never reach the automation engine's connector registry — only plugins populate it via engine.registerConnector(def, handlers) (ADR-0018 §Addendum) — so a declared connector with actions and no plugin behind it looked dispatchable but was silently inert.

    The contract is now explicit and audited:

    • Boot audit (service-automation). At kernel:ready (and again on metadata:reloaded), declared connectors with actions but no same-name runtime registration log a loud warning naming each inert entry and pointing at the fix (install the matching connector plugin, or mark a deliberate catalog entry). Nothing is registered on your behalf — the warning surfaces the gap connector_action would otherwise hit at dispatch time.
    • enabled: false = deliberate catalog descriptor (spec). Setting it on a declarative entry documents "descriptor-only on purpose" and silences the audit. Schema docs on stack.zod.ts (connectors:) and integration/connector.zod.ts now state the descriptor-vs-registered contract explicitly (including for AI stack authoring via .describe()).

    Declarative provider-bound connector instances — entries a generic executor (connector-openapi / connector-mcp) materializes into live connectors at boot, upgrading this warning to a hard error — are specified in ADR-0096 and tracked in #2977.

Patch Changes

  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [3fe9df1]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [4109153]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [627f225]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
  • Updated dependencies [f531a26]
    • @objectstack/spec@15.1.0
    • @objectstack/core@15.1.0
    • @objectstack/formula@15.1.0

15.0.0

Patch Changes

  • Updated dependencies [28b7c28]
  • Updated dependencies [13749ec]
  • Updated dependencies [e62c233]
  • Updated dependencies [ed61c9b]
  • Updated dependencies [31d04d4]
    • @objectstack/spec@15.0.0
    • @objectstack/core@15.0.0
    • @objectstack/formula@15.0.0

14.8.0

Minor Changes

  • 16b4bf6: ADR-0087 P1:元数据转换层(conversion layer,D2)——大多数破坏性变更对使用方零操作。

    @objectstack/spec 新增 conversions/ 模块:一张按协议大版本组织、声明式、无损的转换表,在加载时(normalizeStackInput —— defineStack / objectstack validate / lint / info / doctor 共用的同一入口)把旧(N−1)形态的元数据改写为规范的 N 形态,并对每处改写发出结构化弃用通知(OS_METADATA_CONVERTED)。使用方仍按旧形态编写也能零操作加载,运行时只会看到规范形态。这是把 Kubernetes storage-version/conversion 模型套用到元数据上;它与 Prime Directive #12 禁止的“使用方侧方言兜底”在每个维度上都相反:一张集中、随 spec 版本化、声明化、显式(每次应用都发通知)、带测试(每条附 old→new fixture)、会过期(仅在一个大版本内加载期生效,之后退役并沉淀进 P2 迁移链)的表,而非散落的 cfg.a ?? cfg.b

    首批以已发布的 protocol 11 重命名回填播种:

    • flow-node-http-callout-rename:流程回调节点 http_request / http_call / webhookhttp
    • page-kind-jsx-to-html:页面 kind: 'jsx''html'(ADR-0080 规范拼写)。
    • flow-node-crud-filter-alias:CRUD 流程节点 config.filtersconfig.filter

    运行时加载 seam(存量流程零回归的关键)。 转换不仅接在构建/校验入口,也接到运行时 AutomationEngine.registerFlow(在 FlowSchema.parse 之前跑,新增 applyConversionsToFlow)。这样从数据库 rehydrate 的存量流程也会被规范化——否则删掉 filters 执行器兜底会让存量 delete_record / update_record 的过滤条件被静默清空(退化成作用于全表)。这才真正兑现 D2 “applied at load, the same seam”。

    开放命名空间的冲突守卫(第三方零静默误伤)。 flow.node.type 是开放命名空间(ADR-0018 移除了 enum gate),退役的官方名可能被第三方复用为自定义节点。转换层新增“保留名冲突”感知:运行时 seam 传入本环境已注册的执行器类型,若某退役别名(http_request/http_call/webhook)正被活的自定义执行器占用,则拒绝改写并发出响亮的结构化告警 OS_METADATA_CONVERSION_CONFLICT(带节点位置、conversion id、“请改名”的处置建议),而不是静默把它改成 http 破坏第三方节点。构建/校验入口无注册表上下文,历史别名照常转换。

    并落实 PD #12 退役路径示范:filtersfilter 别名从 service-automation 执行器的 readAliasedConfig 兜底中删除,提升为上面这条声明式转换条目;执行器改为直接读取规范键 cfg.filter

    新增导出(纯增量,无破坏):applyConversionsapplyConversionsToFlowcollectConversionNoticesALL_CONVERSIONSCONVERSIONS_BY_MAJORCONVERSION_NOTICE_CODECONVERSION_CONFLICT_CODE,以及类型 MetadataConversionConversionNoticeConversionApplicationConversionFixtureConversionContextConversionConflictNoticeConversionConflictDetailApplyConversionsOptionsNormalizeStackInputOptionsnormalizeStackInput 现接受可选第二参 { onConversionNotice, convert }(向后兼容)。

Patch Changes

  • Updated dependencies [16b4bf6]
  • Updated dependencies [16b4bf6]
  • Updated dependencies [10e8983]
  • Updated dependencies [607aaf4]
  • Updated dependencies [bb71321]
    • @objectstack/spec@14.8.0
    • @objectstack/core@14.8.0
    • @objectstack/formula@14.8.0

14.7.0

Patch Changes

  • Updated dependencies [d6a72eb]
    • @objectstack/spec@14.7.0
    • @objectstack/core@14.7.0
    • @objectstack/formula@14.7.0

14.6.0

Patch Changes

  • Updated dependencies [609cb13]
  • Updated dependencies [ce6d151]
    • @objectstack/spec@14.6.0
    • @objectstack/core@14.6.0
    • @objectstack/formula@14.6.0

14.5.0

Minor Changes

  • 33ebd34: ADR-0057 (#2834): retention.onlyWhen status predicate — mixed tables can scope the age reap.

    • spec: lifecycle.retention.onlyWhen — a row filter (per-field equality or { $in: [...] }) the retention window applies to; rows outside it are retained regardless of age. Rejected when combined with rotation storage (shard DROPs ignore filters) or archive (the Archiver moves rows by age alone).
    • objectql: the LifecycleService Reaper merges onlyWhen into every retention delete, including tenant-override passes.
    • service-automation: the run-history age sweep is now declarative — sys_automation_run declares retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } } and the platform Reaper owns it; suspended (paused) runs never match. The plugin's own sweep loop is retired: ObjectStoreSuspendedRunStore.pruneHistory, the DEFAULT_RUN_HISTORY_RETENTION_DAYS export, and the runHistoryRetentionDays / runHistorySweepMs plugin options are removed (launch-window breaking-as-minor). The write-time per-flow overflow cap (runHistoryMaxPerFlow) stays.

Patch Changes

  • 526805e: ADR-0057 data-lifecycle follow-ups (#2834): the per-plugin retention sweepers are retired, telemetry separation goes live in dev, and the lifecycle contract reaches the Studio.

    • BREAKING (ships as minor per the launch-window convention): JobRunRetention / NotificationRetention and the retentionDays / retentionSweepMs options on JobServicePlugin / MessagingServicePlugin are removed. The platform LifecycleService enforces the same windows from the lifecycle declarations (sys_job_run 30d, notification pipeline 90d); tune them at runtime via the lifecycle settings namespace (retention_overrides, tenant-scoped).
    • Fix: sys_automation_run no longer declares a blanket 30d lifecycle retention — that table interleaves live SUSPENDED runs (an approval may stay paused for months) with terminal history, and a blanket age reap could strand in-flight approvals. Bounding stays with the automation store's terminal-only sweep.
    • CLI: objectstack dev now provisions a dedicated telemetry datasource (<primary>.telemetry.db) for file-backed SQLite primaries, so lifecycle-classed system data stops sharing the business dev DB (OS_TELEMETRY_DB=0 opts out; OS_TELEMETRY_DB=<path> opts in anywhere). New os db clean runs the one-time VACUUM that lets legacy files adopt auto_vacuum=INCREMENTAL and reports reclaimed bytes.
    • Studio: the object metadata form exposes the lifecycle block (class + retention/TTL/rotation/archive/reclaim); metadata-forms i18n bundles regenerated with curated zh-CN translations.
  • Updated dependencies [526805e]

  • Updated dependencies [d79ca07]

  • Updated dependencies [33ebd34]

  • Updated dependencies [c044f08]

  • Updated dependencies [01274eb]

    • @objectstack/spec@14.5.0
    • @objectstack/core@14.5.0
    • @objectstack/formula@14.5.0

14.4.0

Minor Changes

  • 7953832: ADR-0057 data lifecycle P1–P4 (#2786): platform-generated data is now bounded by construction.

    • P1 — contract: new lifecycle object property (class: record | audit | telemetry | transient | event + retention / ttl / storage(rotation) / archive / reclaim), enforced by the platform-owned LifecycleService registered by ObjectQLPlugin (default-on; disable via OS_LIFECYCLE_DISABLED=1 or plugin lifecycle.enabled=false). The Reaper batch-deletes rows past retention.maxAge / ttl under a system context and reclaims space (SqlDriver.reclaimSpace() → SQLite PRAGMA incremental_vacuum). Non-record classes must declare a bounding policy (parse-time invariant + spec-liveness gate + dogfood storage-growth gate).
    • P2 — rotation: storage: { strategy: 'rotation', shards, unit } physically time-shards the table on SQLite — writes land in the current shard, reads go through a UNION-ALL view under the base name, expiry is an O(1) DROP of shards past the window. A legacy table is adopted as the first shard on upgrade. Other dialects fall back to an equivalent age-based reap.
    • P3 — separation + Archiver: registering a datasource named telemetry routes telemetry/event/audit objects to it (opt-in by existence; transient deliberately stays on the primary). Audit objects with archive declared get retain → archive → delete once the archive datasource exists; without it rows are retained, never dropped unarchived.
    • P4 — governance: new lifecycle settings namespace — runtime enable switch, per-object retention overrides (tenant-scoped: regulated tenants set years, dev sets days), per-object/per-class row quotas and growth alerts (observe-and-alert only).

    Behavior change: 11 platform objects now carry lifecycle declarations and their telemetry is bounded by default — sys_activity 14d (rotated), sys_audit_log 90d hot → archive (retained forever until an archive datasource is registered), sys_metadata_audit 365d → archive, sys_job_run / sys_automation_run / sys_http_delivery 30d, notification pipeline (sys_notification, delivery, receipt, inbox) 90d, sys_device_code expires_at + 1d. Extend windows per environment/tenant via the lifecycle.retention_overrides setting.

Patch Changes

  • Updated dependencies [7953832]
  • Updated dependencies [82e745e]
  • Updated dependencies [f3035bd]
  • Updated dependencies [82c0d94]
  • Updated dependencies [7449476]
    • @objectstack/spec@14.4.0
    • @objectstack/core@14.4.0
    • @objectstack/formula@14.4.0

14.3.0

Patch Changes

  • Updated dependencies [2a71f48]
  • Updated dependencies [02f6af4]
  • Updated dependencies [c1064f1]
    • @objectstack/spec@14.3.0
    • @objectstack/core@14.3.0
    • @objectstack/formula@14.3.0

14.2.0

Patch Changes

  • Updated dependencies [ac8f029]
  • Updated dependencies [4ab9958]
    • @objectstack/spec@14.2.0
    • @objectstack/core@14.2.0
    • @objectstack/formula@14.2.0

14.1.0

Patch Changes

  • Updated dependencies [5a8465f]
  • Updated dependencies [7f8620b]
  • Updated dependencies [82ba3a6]
    • @objectstack/spec@14.1.0
    • @objectstack/core@14.1.0
    • @objectstack/formula@14.1.0

14.0.0

Patch Changes

  • Updated dependencies [0a8e685]
  • Updated dependencies [afa8115]
  • Updated dependencies [80f12ca]
  • Updated dependencies [e2fa074]
  • Updated dependencies [23c8668]
  • Updated dependencies [29f017d]
  • Updated dependencies [216fa9a]
  • Updated dependencies [6c22b12]
    • @objectstack/spec@14.0.0
    • @objectstack/core@14.0.0
    • @objectstack/formula@14.0.0

13.0.0

Major Changes

  • 6d83431: ADR-0090 P1 breaking wave — permission model v2 concept convergence.

    Pre-launch one-step renames and secure defaults (no compatibility aliases, per ADR-0090 D3/D4 superseding ADR-0057 D5/D7's alias discipline):

    • sys_rolesys_position, sys_user_rolesys_user_position (field roleposition), sys_role_permission_setsys_position_permission_set (field role_idposition_id); RoleSchema/defineRolePositionSchema/definePosition with no parent (positions are flat; hierarchy lives on the business-unit tree).
    • ExecutionContext.roles[]positions[]; the EvalUser/CEL contract current_user.rolescurrent_user.positions (formula validators updated); stack property roles:positions:; metadata kinds role/profileposition (profile kind removed).
    • isProfile removed from PermissionSetSchema (ADR-0090 D2); isDefault narrows to an install-time suggestion; appDefaultProfileNameappDefaultPermissionSetName (isDefault-only).
    • OWD enum drops legacy aliases read/read_write/full; new optional externalSharingModel (external dial, private default) lands as P1 spec shape (ADR-0090 D11).
    • Secure default (D1): a custom object with an owner field and NO sharingModel now resolves private (was: fully public). System objects keep their explicit posture. Unrecognised stored values fail closed.
    • ExecutionContext gains the P1 principal-taxonomy shape (D10): principalKind / audience / onBehalfOf (optional, semantics phase in later).
    • Sharing recipients: roleposition (expanded via sys_user_position ∪ the better-auth membership transition source); role_and_subordinates removed — unit_and_subordinates now expands the business-unit subtree (finishes ADR-0057 D5's re-homing).

Patch Changes

  • Updated dependencies [6d83431]
  • Updated dependencies [01917c2]
  • Updated dependencies [b271691]
  • Updated dependencies [a5a1e41]
  • Updated dependencies [466adf6]
  • Updated dependencies [5be00c3]
  • Updated dependencies [466adf6]
  • Updated dependencies [2bee609]
  • Updated dependencies [fc7e7f7]
    • @objectstack/spec@13.0.0
    • @objectstack/core@13.0.0
    • @objectstack/formula@13.0.0

12.6.0

Minor Changes

  • 0adcc1c: Flow notify node: support a click-through target so inbox notifications can be clicked into the related record (#2675).

    The notify node now reads sourceObject / sourceId (or the nested source: { object, id } form) and actorId from its config and forwards them to the messaging service, which persists sys_notification.source_object / source_id / actor_id and synthesizes a /{object}/{id} inbox deep-link. Both keys interpolate flow variables (e.g. sourceId: '{new_quotation.id}'), and a half-specified target (object without id, or vice versa) is dropped so the inbox never renders a dead link. url is now accepted as an alias for actionUrl (an explicit URL still overrides the synthesized link). The node also publishes a configSchema documenting all accepted keys for the Studio form.

    Previously the node consumed only recipients / title / message / channels, so every notification it emitted had source_object / source_id = null and could not be clicked through to a record.

Patch Changes

  • Updated dependencies [6cebf22]
  • Updated dependencies [21420d9]
    • @objectstack/spec@12.6.0
    • @objectstack/core@12.6.0
    • @objectstack/formula@12.6.0

12.5.0

Patch Changes

  • 8b3d363: Package metadata seed can no longer wedge the platform via record-change automation.

    A seeded record whose lifecycle flow self-triggered (a record-after-update flow writing back to its own trigger record) looped forever when its boolean re-entry guard never tripped — booleans persist as integer 1 on SQLite/libsql and CEL 1 != true is true. During first-boot seed (which awaits automation) this hung the whole kernel build.

    Three layers:

    • ExecutionContext.skipTriggers (set by the seed-loader, threaded onto HookContext.session via buildSession) makes the record-change trigger skip flow dispatch for seed/bulk writes — seed data is end-state reference data, not user events. Lifecycle hooks still run.
    • coerceBooleanFields() converts SQLite 0/1 (and '0'/'1'/'true'/'false') to real booleans on the after-hook view of a record (hookContext.result / .previous), so flow conditions see JS booleans. The value returned to the caller is unchanged.
    • The automation engine breaks a flow re-entering for the same record while an execution is still on the stack (activeRecordFlows), a backstop for any self-trigger loop.
  • Updated dependencies [8b3d363]

    • @objectstack/spec@12.5.0
    • @objectstack/core@12.5.0
    • @objectstack/formula@12.5.0

12.4.0

Patch Changes

  • Updated dependencies [60dc3ba]
    • @objectstack/spec@12.4.0
    • @objectstack/core@12.4.0
    • @objectstack/formula@12.4.0

12.3.0

Patch Changes

  • Updated dependencies [e7eceec]
    • @objectstack/spec@12.3.0
    • @objectstack/core@12.3.0
    • @objectstack/formula@12.3.0

12.2.0

Patch Changes

  • Updated dependencies [fce8ff4]
  • Updated dependencies [3962023]
  • Updated dependencies [2bb193d]
  • Updated dependencies [0426d27]
  • Updated dependencies [da807f7]
  • Updated dependencies [4f5b791]
    • @objectstack/spec@12.2.0
    • @objectstack/core@12.2.0
    • @objectstack/formula@12.2.0

12.1.0

Minor Changes

  • 8bcd994: Automation run observability follow-ups (#2585): retention for sys_automation_run run history, and durable single-run detail.

    Retention (closes the unbounded-growth risk #2581 introduced). Terminal run-history rows are now bounded by default, ADR-0057 posture:

    • A write-time per-flow cap keeps the newest 100 terminal runs per flow (runHistoryMaxPerFlow, 0 disables).
    • A default-on periodic sweep deletes terminal rows older than 30 days (runHistoryRetentionDays, 0 disables; runHistorySweepMs tunes the interval, default 1 h).
    • Suspended (paused) rows are live resumable state and are never pruned.

    Durable single-run detail. AutomationEngine.getRun(runId) now falls back to the durable history row when the run is no longer in the in-memory buffer (e.g. after a restart), and terminal rows persist a bounded per-node step log (steps_json: newest 200 steps, stacks stripped, 64 KB cap) — so "open a past failed run and see which node blew up" survives a restart. New SuspendedRunStore.loadTerminal(runId) backs this; RunRecord gains finishedAt and steps.

  • 497bda8: feat(automation): honor flow deployment status for enable/disable + expose runtime enable/bound state

    The engine bound and ran every registered flow, ignoring the flow's persisted status — so an author had no way to turn an automation off (short of deleting it) and no way to see whether one was actually live. This is the engine half of the Studio's "clear on/off switch + visible enabled/bound status".

    • registerFlow now honors status: a flow whose deployment status is obsolete or invalid is treated as disabled — its trigger is not bound and execute() refuses it. draft / active — and any legacy flow with no explicit status — stay enabled, so existing flows are unaffected (zero regression; this is the on/off switch persisting via the existing status field, applied on the next publish rebind). A status flip back OUT of a disabled state re-enables on re-register even if the flow had been turned off; a runtime toggleFlow() override on a still-enabled flow is preserved.

    • New getFlowRuntimeStates() + GET /api/v1/automation/_status: returns [{ name, enabled, bound }] for every registered flow — the truth behind the Studio's status badges (persisted status is metadata; whether a flow is actually enabled and wired to its trigger is engine state). Underscore-prefixed so no flow name can shadow the route; degrades to an empty list on an older service.

    Tests cover: draft/active flows bind + enable (unchanged), an obsolete flow is neither bound nor enabled and execute() refuses it, a status flip obsolete→active re-enables + re-binds, and the _status route shape.

Patch Changes

  • Updated dependencies [93e6d02]
    • @objectstack/spec@12.1.0
    • @objectstack/core@12.1.0
    • @objectstack/formula@12.1.0

12.0.0

Minor Changes

  • ffafb30: feat(automation): durable run history — every terminal run leaves a queryable record with its failure reason

    Automation runs were observable only in memory: the engine kept the last N ExecutionLogEntry records in a ring buffer, so "did this flow run, and why did it fail?" could not be answered after a process restart (or once the buffer evicted the entry), and a failed run surfaced no reason at all. This was the biggest silent-trust gap for anyone authoring automations — a flow could stop firing or start failing with nothing durable to inspect.

    sys_automation_run — previously the ADR-0019 store for live suspended runs only — becomes a durable run-history table. On every terminal run the engine mirrors a row through the SuspendedRunStore (recordTerminal): status (completed / failed), finished_at, duration_ms, and, for a failure, the error message a designer needs to fix it. listRuns() merges this durable history with the in-memory buffer (in-memory wins on id, newest-first) so the Studio "Runs" surface shows runs that predate the current process.

    The design is safe and additive. Terminal history rows use a run_-prefixed id, disjoint from live suspended runs (which key on the raw runId with status: 'paused'), so the suspend save/load/delete/list path is untouched and resume sweeps (list() filters status: 'paused') never see history rows. Persisting is best-effort and fire-and-forget — a history-write failure is logged and swallowed, never breaking the run that produced it. New object fields (finished_at, duration_ms, error) are all optional and the status enum gains running / completed / failed alongside the existing paused.

    Verified end-to-end on a clean showcase instance: a schedule-triggered flow and seven task-completion flows each left durable completed rows; a genuinely failing flow (showcase_resilient_sync) left a failed row carrying its try_catch failure reason; a live paused suspended run coexisted without collision; and after a full process restart the failed row — reason intact — was still queryable via /api/v1/data/sys_automation_run. New run-history.test.ts covers completed/failed persistence, read-across-restart, and best-effort isolation.

Patch Changes

  • f84f8d5: fix(automation): bind flow triggers on a cold boot, not just after an HMR reload

    Record-triggered (and other trigger-typed) flows silently never fired on a fresh process start — in dev and in production. The automation service's boot-time flow pull reads ql.registry.listItems('flow'), which is empty for flows defined inline in an app manifestregistry.registerApp() stores the app under type 'app' and never promotes its inline flows to standalone registry 'flow' items. The re-sync that could see them only ran on the metadata:reloaded hook, which never fires on a cold boot (os dev restarts the process on recompile rather than firing it, and production never reloads).

    Net effect: after any real restart, no flow bound its trigger, so record-change automations did not fire at all.

    Fix: bind flows at kernel:ready from protocol.getMetaItems({ type: 'flow' }) — the canonical flattened flow view that GET /meta/flow serves and that does surface inline app flows — once every plugin has finished init()/start() (so the app, hence its flows, is registered). registerFlow is idempotent, so re-binding a flow the boot pull already registered is harmless.

    Verified end-to-end on a clean instance: before the fix, updating a record fired 0 flows (0 bound at boot); after, a cold boot binds all flows and a single record update fires every matching record-triggered flow. Regression test boots a kernel with an inline-app record-triggered flow served only via protocol.getMetaItems and asserts it is bound after bootstrap() alone with no metadata:reloaded fired — it fails on the pre-fix code.

  • 9693a36: fix(automation): bind a flow published while the server runs, without a restart

    Follow-up to #2560 (cold-boot flow binding). A flow published while the server is running — the Studio online-authoring journey: author a record-triggered automation, publish it, immediately update a matching record — did not fire. Its trigger only bound on the next process restart.

    Two gaps, both fixed:

    1. The publish path fired no rebind signal. POST /packages/:id/publish-draftsprotocol.publishPackageDrafts promotes the drafts to active but emitted no event the automation service listens to. The runtime dispatcher now announces metadata:reloaded after a successful publish — the same signal a dev artifact reload fires (MetadataPlugin._reloadAndAnnounce) — so boot-cached consumers re-sync without a restart.

    2. The runtime re-sync read the wrong source. The automation service's metadata:reloaded re-sync pulled metadata.list('flow'), which returns 0 in a real running server (it does not surface inline app flows), so even when the hook fired it bound nothing. It now reads protocol.getMetaItems({ type: 'flow' }) — the same flattened flow view #2560's cold-boot bind and GET /meta/flow use — while keeping the teardown of flows removed from the artifact. A failed or unavailable protocol read is a no-op and never tears down live flows.

    Production is largely unaffected (a deploy reboots the process, so #2560's cold-boot bind covers it); this closes the gap for dev and single-instance Studio authoring.

    Verified end-to-end on a clean instance: authored a record-triggered flow in a package, published it via POST /packages/:id/publish-drafts without restarting, then updated a matching record and observed the flow fire (before the fix it did not). New regression tests boot a kernel whose protocol serves a flow only after boot and assert metadata:reloaded binds it — and that the re-sync reads the protocol, not metadata.list — both failing on the pre-fix code.

  • Updated dependencies [a8df396]

  • Updated dependencies [e695fe0]

  • Updated dependencies [7c09621]

  • Updated dependencies [7709db4]

  • Updated dependencies [2082109]

  • Updated dependencies [7c09621]

  • Updated dependencies [9860de4]

  • Updated dependencies [069c205]

    • @objectstack/spec@12.0.0
    • @objectstack/core@12.0.0
    • @objectstack/formula@12.0.0

11.10.0

Patch Changes

  • 6a9397e: Retire the deprecated compactLayout alias for highlightFields (framework#2536, closes the ADR-0085 deprecation window).

    • ObjectSchema no longer declares compactLayout: create() rejects it like any unknown key; lenient parse() strips it (no silent aliasing).
    • The parse-time alias AND the highlightFields → compactLayout back-fill transition mirror are removed from normalizeSemanticRoleAliases. Served metadata now carries the canonical key only.
    • All remaining first-party authors (27 system objects across plugin-audit / approvals / security / sharing / webhooks / service-storage / automation / messaging / realtime — missed by the #2521 sweep, caught by the type gate) renamed to highlightFields.
    • The downstream smoke pin moves to hotcrm v1.2.2 (hotcrm#424: same rename + deps ^11.7.0).
    • Consumers were switched in objectui#2168 and shipped via the console pin bump (#2526); this closes the window scheduled there. The dogfood mirror assertion (#2528) flips to compactLayout: undefined in this same change, per the plan it carried.

    Version note: minor, not major — the key was deprecated-with-alias for a full release window, all first-party consumers/authors are migrated, and the spec api-surface gate reports no export changes (same documented-exception path as the ADR-0085 removals in 11.7.0). External metadata still authoring compactLayout will now fail create() loudly with the standard unknown-key error naming the key.

  • Updated dependencies [6a9397e]

  • Updated dependencies [c0efe5d]

    • @objectstack/spec@11.10.0
    • @objectstack/core@11.10.0
    • @objectstack/formula@11.10.0

11.9.0

Patch Changes

  • Updated dependencies [d3595d9]
    • @objectstack/spec@11.9.0
    • @objectstack/core@11.9.0
    • @objectstack/formula@11.9.0

11.8.0

Patch Changes

  • @objectstack/spec@11.8.0
  • @objectstack/core@11.8.0
  • @objectstack/formula@11.8.0

11.7.0

Patch Changes

  • Updated dependencies [5178906]
    • @objectstack/spec@11.7.0
    • @objectstack/core@11.7.0
    • @objectstack/formula@11.7.0

11.6.0

Patch Changes

  • @objectstack/spec@11.6.0
  • @objectstack/core@11.6.0
  • @objectstack/formula@11.6.0

11.5.0

Patch Changes

  • Updated dependencies [6ee4f04]
  • Updated dependencies [c1e3a65]
    • @objectstack/spec@11.5.0
    • @objectstack/core@11.5.0
    • @objectstack/formula@11.5.0

11.4.0

Patch Changes

  • Updated dependencies [5821c51]
  • Updated dependencies [a0fce3f]
    • @objectstack/spec@11.4.0
    • @objectstack/core@11.4.0
    • @objectstack/formula@11.4.0

11.3.0

Patch Changes

  • Updated dependencies [58e8e31]
  • Updated dependencies [b4a5df0]
    • @objectstack/spec@11.3.0
    • @objectstack/core@11.3.0
    • @objectstack/formula@11.3.0

11.2.0

Patch Changes

  • Updated dependencies [d0f4b13]
  • Updated dependencies [302bdab]
    • @objectstack/spec@11.2.0
    • @objectstack/core@11.2.0
    • @objectstack/formula@11.2.0

11.1.0

Patch Changes

  • Updated dependencies [ce0b4f6]
  • Updated dependencies [9ccfcd6]
  • Updated dependencies [ecf193f]
  • Updated dependencies [51bec81]
  • Updated dependencies [3e593a7]
  • Updated dependencies [63d5403]
    • @objectstack/core@11.1.0
    • @objectstack/spec@11.1.0
    • @objectstack/formula@11.1.0

11.0.0

Major Changes

  • 82ff91c: Remove the deprecated http_request / http_call / webhook flow-node aliases — author http (ADR-0018 M3).

    ADR-0018 M3 collapsed the divergent outbound-callout verbs onto the canonical http node and kept the old names as deprecated aliases for back-compat. This removes those aliases (the 11.0 cleanup):

    • http_request is dropped from FlowNodeAction (and therefore FLOW_BUILTIN_NODE_TYPES); authoring it now fails fast at parse instead of resolving to http.
    • AutomationEngine no longer registers the http_request / http_call / webhook node aliases; only http is registered.
    • The flow-builder palette offers http.

    Breaking. Flows / workflow rules / approval actions that still use the old node type must switch to type: 'http' (behavior is identical — durable outbox when config.durable, inline fetch otherwise). The trigger eventType: 'webhook' and the webhook resume event are unaffected — only the HTTP node aliases are removed. First-party examples (showcase, app-crm) are migrated.

Minor Changes

  • 6c4fbd9: fix(security): enforce flow runAs execution identity (#1888)

    The service-automation engine now honors flow.runAs instead of ignoring it. Previously the CRUD nodes passed no identity to ObjectQL, so the security middleware was skipped entirely — every flow ran effectively elevated regardless of runAs. A runAs:'user' flow did not de-elevate (a privilege-boundary surprise), and runAs:'system' did not explicitly elevate.

    The engine now establishes the run's data-layer identity at setup and restores the caller's context afterward:

    • runAs:'system' → an elevated, RLS-bypassing system principal ({ isSystem: true }): the run can read/write records the triggering user cannot.
    • runAs:'user' (default) → the triggering user's identity ({ userId, roles, permissions, tenantId }): CRUD nodes' ObjectQL reads/writes respect that user's row-level security, and the run can never exceed the triggering user's grants.

    To keep runAs:'user' faithful to a direct request by that user, the REST trigger route (@objectstack/runtime) and the record-change trigger (@objectstack/trigger-record-change) now forward the caller's resolved roles/tenantId into the AutomationContext (new optional fields), not just userId. The new resolveRunDataContext helper is the single place that maps a run's effective runAs to the ObjectQL context, shared by every data node.

    The [EXPERIMENTAL — not enforced] marker is removed from FlowSchema.runAs.

    Behavior change / migration. Flows that previously relied on the implicit elevation (the default runAs:'user' ran unscoped) now run as the triggering user and are subject to their RLS. Declare runAs:'system' on any flow that must read or write beyond the triggering user's access (e.g. system automations, cross-owner roll-ups). Schedule-triggered runs have no trigger user; under user they stay unscoped (there is no identity to scope to) — declare system to make elevation explicit.

    Proven both directions by the dogfood regression gate (flow-runas.dogfood.test.ts — a restricted member triggers system vs user flows against an owner-scoped record) and service-automation unit + regression tests (crud-runas.test.ts).

  • ad143ce: fix(security): surface the schedule/user-less runAs:'user' fail-open (#1888 follow-up)

    With flow.runAs now enforced (#1888), a schedule-triggered flow with the default runAs:'user' has no trigger user. resolveRunDataContext returns undefined for that case, so the CRUD nodes pass no ObjectQL options.context and the security middleware — which skips when there is no identity (it delegates auth to the auth layer) — runs the operation UNSCOPED (effectively elevated). An author who left runAs at the 'user' default expecting a restricted run silently gets an unscoped one — a fail-open footgun (ADR-0049: a security property must not silently do the opposite of what it implies).

    This is the product decision to make that explicit, chosen to keep legitimate scheduled CRUD working (denying outright would break it, and silently elevating would hide the author's intent). Prevention happens where the platform can tell intent apart (author/build time); the runtime stays non-breaking but is no longer silent:

    • Author-time lint (@objectstack/cli, lintFlowPatterns): a new advisory rule flow-schedule-runas-unscoped flags a schedule-triggered flow whose effective runAs is user (explicit or unset) and which performs a data operation — pointing the author at runAs:'system'. Catches the footgun at compile time, before deploy (most flows are AI-authored).
    • Runtime warning (@objectstack/service-automation): the engine now emits a clear one-per-run warning when a user-mode run resolves no trigger identity and the flow touches data — the fail-open is audible rather than silent. Behavior is otherwise unchanged (the run still executes), so scheduled CRUD that relied on this is not broken. New helpers runIsUnscopedUserMode, flowTouchesData, and DATA_NODE_TYPES are exported alongside resolveRunDataContext.
    • Spec describe (@objectstack/spec): FlowSchema.runAs now states that a scheduled run has no user, so under user it runs unscoped — declare system.

    The first-party example apps that tripped the new lint are fixed to declare runAs:'system' explicitly (stale_opportunity_sweep, the app-todo task_reminder / overdue_escalation sweeps) — they read/write across owners and were running unscoped by default.

    Longer term, attributing scheduled runs to a dedicated service principal (so they are scopable + audit-attributable rather than unscoped) is the right enforcement; tracked as M2 follow-up.

    Proven by a service-automation unit test (the engine warns once for a user-less user-mode data run; stays silent for system, for an identified user, and for a data-less flow), an end-to-end test wiring the real ScheduleTrigger to the real engine (@objectstack/trigger-schedule) that fires a job and asserts the user-less identity reaches the engine + trips the warning through the actual cron path, and a dogfood gate (flow-runas-schedule.dogfood.test.ts) that drives user-less runs through the real automation + security + data stack: a runAs:'user' run reads + writes an owner-scoped note a member cannot — audibly — while runAs:'system' is the explicit, warning-free equivalent.

    Refs #1888, ADR-0049.

Patch Changes

  • 4b5ec6e: fix(automation): re-bind scheduled-flow jobs on os dev hot-reload

    Editing a schedule-triggered flow under objectstack dev silently kept firing the OLD definition until a full server restart. The dev watcher recompiles dist/objectstack.json and MetadataPlugin reloads it into the MetadataManager (so GET /meta reads + UI HMR are fresh), but the AutomationEngine pulls its flow definitions and trigger/job bindings ONCE at boot — nothing re-registered them on reload. So the scheduled job bound at boot kept running the pre-edit flow (old runAs, schedule, or logic) on its timer, with no signal that the edit had no effect.

    Fix: MetadataPlugin now fires a generic metadata:reloaded hook after each artifact reload (the HMR POST handler and the server-side artifact-file watcher; never on the initial boot load). AutomationServicePlugin subscribes and re-syncs the engine from the metadata service — re-registering every current flow (idempotent: registerFlow re-binds the trigger, and ScheduleTrigger.start cancels + reschedules the job) and unregistering flows removed from the artifact so their jobs stop firing. This covers all auto-triggered flow types (schedule / record-change / api), not just scheduled ones, since record-change flows were also executing their boot-time definitions after an edit. Production deployments are unaffected — nothing reloads the artifact there.

  • b6a4972: fix(automation): honor the assignments wrapper shape on assignment nodes

    The built-in assignment node executor set each TOP-LEVEL config key as a flow variable. But the surfaces that author these nodes all emit an assignments wrapper instead:

    • Studio's visual Assignment editor → config: { assignments: { <var>: <value> } }
    • bundled example flows (app-crm, showcase) → config: { assignments: [{ variable, value }] }

    So a node designed in Studio (or any of the shipped examples) silently set a single variable literally named assignments to the whole map/array and never set the intended variables — it passed build and no-oped at run time, leaving every downstream reference unresolved.

    The executor now normalizes all three shapes (assignments map, assignments array of { variable | name | key, value }, and the legacy flat { <var>: <value> }) and interpolates {var} templates in the values, matching the CRUD / screen nodes. Adds logic-nodes.test.ts covering each shape as a regression guard.

  • Updated dependencies [ab5718a]

  • Updated dependencies [4845c12]

  • Updated dependencies [c1a754a]

  • Updated dependencies [6fbe91f]

  • Updated dependencies [715d667]

  • Updated dependencies [5eef4cf]

  • Updated dependencies [72759e1]

  • Updated dependencies [6c4fbd9]

  • Updated dependencies [ef3ed67]

  • Updated dependencies [cd51229]

  • Updated dependencies [7697a0e]

  • Updated dependencies [e7e04f1]

  • Updated dependencies [cfd5ac4]

  • Updated dependencies [2be5c1f]

  • Updated dependencies [ad143ce]

  • Updated dependencies [5c4a8c8]

  • Updated dependencies [3afaeed]

  • Updated dependencies [8801c02]

  • Updated dependencies [3d04e06]

  • Updated dependencies [4a84c98]

  • Updated dependencies [c715d25]

  • Updated dependencies [aa33b02]

  • Updated dependencies [d980f0d]

  • Updated dependencies [a658523]

  • Updated dependencies [82ff91c]

  • Updated dependencies [638f472]

    • @objectstack/spec@11.0.0
    • @objectstack/formula@11.0.0
    • @objectstack/core@11.0.0

10.3.0

Patch Changes

  • @objectstack/spec@10.3.0
  • @objectstack/core@10.3.0
  • @objectstack/formula@10.3.0

10.2.0

Patch Changes

  • Updated dependencies [b496498]
    • @objectstack/spec@10.2.0
    • @objectstack/core@10.2.0
    • @objectstack/formula@10.2.0

10.1.0

Patch Changes

  • Updated dependencies [49da36e]
  • Updated dependencies [ac79f16]
    • @objectstack/spec@10.1.0
    • @objectstack/core@10.1.0
    • @objectstack/formula@10.1.0

10.0.0

Patch Changes

  • Updated dependencies [d7ff626]
  • Updated dependencies [2a1b16b]
  • Updated dependencies [e16f2a8]
  • Updated dependencies [cfd86ce]
  • Updated dependencies [e411a82]
  • Updated dependencies [a581385]
  • Updated dependencies [d5f6d29]
  • Updated dependencies [220ce5b]
  • Updated dependencies [3efe334]
  • Updated dependencies [feead7e]
  • Updated dependencies [6ca20b3]
  • Updated dependencies [5f875fe]
  • Updated dependencies [b469950]
  • Updated dependencies [48a307a]
  • Updated dependencies [25fc0e4]
    • @objectstack/spec@10.0.0
    • @objectstack/formula@10.0.0
    • @objectstack/core@10.0.0

9.11.0

Patch Changes

  • Updated dependencies [e7f6539]
  • Updated dependencies [2365d07]
  • Updated dependencies [6595b53]
  • Updated dependencies [fa8964d]
  • Updated dependencies [36138c7]
  • Updated dependencies [a8e4f3b]
  • Updated dependencies [4c213c2]
  • Updated dependencies [2afb612]
    • @objectstack/spec@9.11.0
    • @objectstack/core@9.11.0
    • @objectstack/formula@9.11.0

9.10.0

Patch Changes

  • Updated dependencies [db02bd5]
  • Updated dependencies [641675d]
  • Updated dependencies [1f88fd9]
  • Updated dependencies [94e9040]
  • Updated dependencies [1f88fd9]
  • Updated dependencies [1f88fd9]
    • @objectstack/spec@9.10.0
    • @objectstack/formula@9.10.0
    • @objectstack/core@9.10.0

9.9.1

Patch Changes

  • @objectstack/spec@9.9.1
  • @objectstack/core@9.9.1
  • @objectstack/formula@9.9.1

9.9.0

Minor Changes

  • 134043a: feat(automation): declarative screen-flow completion/error messages + action errorMessage

    A screen flow can now declare successMessage / errorMessage (FlowSchema). The engine surfaces them on the terminal AutomationResult (successMessage on success, errorMessage on 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 errorMessage on 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.

  • 6bec07e: feat(automation): object-form screen-flow steps

    A screen node that declares config.objectName now renders the named object's FULL create/edit form (including inline master-detail child grids) instead of a flat field list. The node emits an object-form ScreenSpec (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 to idVariable so 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: ScreenSpec gains kind/objectName/mode/recordId/defaults/idVariable.
    • service-automation: the screen executor emits object-form specs and now interpolates title/description/field defaultValue/object-form defaults against live flow variables (the engine does not pre-interpolate node config).

Patch Changes

  • Updated dependencies [84249a4]
  • Updated dependencies [11af299]
  • Updated dependencies [d5774b5]
  • Updated dependencies [134043a]
  • Updated dependencies [90108e0]
  • Updated dependencies [9afeb2d]
  • Updated dependencies [6bec07e]
  • Updated dependencies [601cc11]
  • Updated dependencies [d99a75a]
  • Updated dependencies [575448d]
    • @objectstack/spec@9.9.0
    • @objectstack/core@9.9.0
    • @objectstack/formula@9.9.0

9.8.0

Patch Changes

  • Updated dependencies [c17d2c8]
  • Updated dependencies [97c55b3]
  • Updated dependencies [1b1f490]
    • @objectstack/formula@9.8.0
    • @objectstack/spec@9.8.0
    • @objectstack/core@9.8.0

9.7.0

Patch Changes

  • Updated dependencies [82c7438]
  • Updated dependencies [417b6ac]
  • Updated dependencies [ff0a87a]
    • @objectstack/formula@9.7.0
    • @objectstack/spec@9.7.0
    • @objectstack/core@9.7.0

9.6.0

Minor Changes

  • 6c82aa0: fix(automation): create_record outputVariable exposes the created record so {var.id} resolves (#1873)

    A create_record node stored only the created record's id string in its outputVariable, so a later node referencing {var.id} (or any {var.<field>}) traversed into a string and resolved to empty — the created record was effectively unreferenceable downstream. get_record already stores the record object (that's why {rec.field} works there); create_record now matches.

    Behavior change: outputVariable holds the created record (an object with id + fields), not the bare id. Reference the id explicitly as {var.id}. A bare {var} that previously yielded the id now yields the record — update such references to {var.id} (the in-repo app-todo create-task flow was updated). When the driver returns a bare id, it's wrapped as { id } so {var.id} still works.

  • dc8b2de: feat(automation): resolve & validate script-node callables; first-class function registration (#1870)

    A flow script node that pointed at an unregistered callable (or declared no actionType/function at all) built fine and silently did nothing at runtime. Two changes close that gap:

    • Loud runtime resolution. The built-in script executor now resolves its target in order — built-in side-effect (email/slack) → a registered function (config.function, or a bare config.actionType that matches no built-in) → otherwise fail the step loudly. The old (no-op handler) success path is gone, so an unwired callable can no longer quietly skip.
    • First-class registration path. AutomationEngine.setFunctionResolver() / resolveFunction() bridge flow nodes to the host function registry. The automation plugin wires it to ObjectQL's resolveFunction (populated from bundle.functions / defineStack({ functions })), so an authored package can register a function and call it from a script node: { type: 'script', config: { function: 'my_fn', inputs: { … } } }.
    • Build-time structural check. objectstack build now flags a script node that declares neither actionType nor function (the actionType: undefined repro). Function existence is verified at runtime — functions are code, not serialized into the artifact.
  • 1402be0: feat(automation): script-node outputVariable + interpolated inputs — the pure-function pattern (#1870)

    A flow function (script node) is a PURE compute step: it receives ctx.input and RETURNS a value. Two additions make the value usable on the flow graph without giving functions raw data access (which would hide I/O from the graph and bypass governance):

    • config.outputVariable exposes the function's return value as a flow variable, so a later declarative node persists it (update_record fields: { x: '{ai.x}' }).
    • config.inputs are now interpolated against the live flow variables, so a function can consume a prior node's output (inputs: { id: '{record.id}' }).

    Data writes stay declarative (visible, governed, build-checkable); data-lifecycle side effects belong in L2 hooks (which get ctx.api), not flow functions.

Patch Changes

  • b0df09c: fix(automation): record-change flows see multi-lookup fields + support array-index interpolation (#1872)

    A multiple: true lookup is an array column the data driver may not echo back on create, so it was absent from the after-create record a record-change flow saw — record.target_channels != null was false and {rec.target_channels.0} resolved empty. Two fixes:

    • trigger-record-change: buildContext now reads the lifecycle hook's input.data (the actual key objectql uses for insert/update; it had been reading a non-existent input.doc) and overlays the after-row on it, so fields the driver didn't return stay visible to the flow's condition + interpolation.
    • service-automation: {var.path.N} numeric segments now index into arrays, so a multi-value lookup can be referenced positionally ({record.channels.0}).
  • ab942f2: feat(automation): accept functionName alias + invoke_function marker on script nodes (#1870 DX)

    AI-authored templates commonly emit config: { actionType: 'invoke_function', functionName: 'my_fn' }, but the runtime only read config.function. Now:

    • config.functionName is accepted as an alias for config.function (runtime + build).
    • actionType: 'invoke_function' is treated as a MARKER ("call the named function") — the name comes from function/functionName, not from actionType itself; it no longer tries to resolve a function literally named invoke_function.
    • objectstack build errors on actionType: 'invoke_function' with no function/functionName (it names no callable) instead of letting it fail at runtime.
  • Updated dependencies [d1e930a]

  • Updated dependencies [71578f2]

  • Updated dependencies [bb00a50]

  • Updated dependencies [5e3a301]

  • Updated dependencies [5db2742]

    • @objectstack/spec@9.6.0
    • @objectstack/formula@9.6.0
    • @objectstack/core@9.6.0

9.5.1

Patch Changes

  • Updated dependencies [ee72aae]
    • @objectstack/spec@9.5.1
    • @objectstack/core@9.5.1
    • @objectstack/formula@9.5.1

9.5.0

Minor Changes

  • f19caef: feat(P1-2): messaging retention default-on; automation log cap configurable

    Closes the remaining two P1-2 unbounded-growth items (launch-readiness):

    • service-messaging — notification-pipeline retention is now default-on. MessagingServicePlugin's retentionDays defaults to DEFAULT_NOTIFICATION_RETENTION_DAYS (90) instead of 0; the already-built/tested sweeper now prunes sys_notification (+ delivery / inbox / receipt) older than 90 days by default. Behaviour change: notification history auto-prunes at 90d — set retentionDays: 0 to keep it forever.
    • service-automation — the in-memory execution-log ring buffer (already bounded; no OOM risk) gets a tunable window via AutomationServicePluginOptions.maxLogSize, defaulting to DEFAULT_MAX_EXECUTION_LOG_SIZE (1000, unchanged). Durable sys_automation_run-style persistence remains a post-GA HA item.

Patch Changes

  • Updated dependencies [d08551c]
  • Updated dependencies [707aeed]
  • Updated dependencies [7a103d4]
  • Updated dependencies [4b01250]
    • @objectstack/spec@9.5.0
    • @objectstack/core@9.5.0
    • @objectstack/formula@9.5.0

9.4.0

Patch Changes

  • Updated dependencies [060467a]
  • Updated dependencies [0856476]
  • Updated dependencies [b678d8c]
  • Updated dependencies [b678d8c]
  • Updated dependencies [b678d8c]
    • @objectstack/spec@9.4.0
    • @objectstack/core@9.4.0
    • @objectstack/formula@9.4.0

9.3.0

Minor Changes

  • 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 as returned (new ApprovalStatus), resumes the run down its revise edge to a wait point where the record lock releases, and the submitter's resubmit() re-enters the approval node over a declared back-edge, opening the next round's request (fresh approver slate, re-locked, round stamped via the config snapshot). Engine: FlowEdgeSchema.type gains '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, and cancelRun(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/revise and /resubmit. Audit kinds revise/resubmit join ApprovalActionKind and the sys_approval_action enum.
  • ad4e97f: ADR-0041 Tier 1 complete: @objectstack/trigger-api — inbound webhook/HTTP flow trigger. The engine now derives an api trigger binding for type: 'api' flows (activating the long-reserved enum value); the trigger mounts POST /api/v1/automation/hooks/:flowName/:hookId with GitHub/Stripe-style HMAC verification (x-objectstack-signature, constant-time compare, identical 404s for unknown flows and wrong hookIds) and queue-backed ingestion — the handler enqueues and ACKs 202, a queue consumer executes the flow with the JSON payload as the trigger record ($record / record.* / bare references), and x-idempotency-key passes through to the queue's dedup window. The CLI's serve preset auto-loads the trigger alongside record-change and schedule.

Patch Changes

  • Updated dependencies [1ada658]
  • Updated dependencies [3219191]
  • Updated dependencies [290f631]
  • Updated dependencies [50b7b47]
  • Updated dependencies [f15d6f6]
  • Updated dependencies [f8684ea]
  • Updated dependencies [b4765be]
    • @objectstack/spec@9.3.0
    • @objectstack/core@9.3.0
    • @objectstack/formula@9.3.0

9.2.0

Patch Changes

  • Updated dependencies [2f57b75]
  • Updated dependencies [2f57b75]
    • @objectstack/spec@9.2.0
    • @objectstack/core@9.2.0
    • @objectstack/formula@9.2.0

9.1.0

Patch Changes

  • Updated dependencies [b9062c9]
    • @objectstack/spec@9.1.0
    • @objectstack/core@9.1.0
    • @objectstack/formula@9.1.0

9.0.1

Patch Changes

  • Updated dependencies [1817845]
    • @objectstack/spec@9.0.1
    • @objectstack/core@9.0.1
    • @objectstack/formula@9.0.1

9.0.0

Patch Changes

  • Updated dependencies [4c3f693]
  • Updated dependencies [0bf39f1]
  • Updated dependencies [f533f42]
  • Updated dependencies [1c83ee8]
    • @objectstack/spec@9.0.0
    • @objectstack/core@9.0.0
    • @objectstack/formula@9.0.0

8.0.1

Patch Changes

  • @objectstack/spec@8.0.1
  • @objectstack/core@8.0.1
  • @objectstack/formula@8.0.1

8.0.0

Patch Changes

  • 3306d2f: feat(automation): surface structured-region body steps in run observability (#1505)

    loop / parallel / try_catch previously 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 new NodeExecutionResult.childSteps field. Each surfaced step is tagged with its immediate container via three new optional fields on ExecutionStepLogSchema (and the engine's StepLogEntry):

    • parentNodeId — the enclosing loop / parallel / try_catch node
    • iteration — zero-based loop iteration or parallel branch index
    • regionKindloop-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_catch retry semantics). Fully additive — existing run logs and consumers are unaffected.

  • bc44195: chore(automation): retire the workflow_rule authoring paradigm (ADR-0018 M5 dropped)

    ADR-0019 already removed the Workflow-Rule → Flow compiler (Workflow Rules were removed in #1398 and workflow was reclaimed for state machines), but the workflow_rule paradigm tag survived in ActionParadigmSchema and on every built-in node descriptor. There is no declarative Workflow-Rule authoring view to feed, so the tag is now retired: ActionParadigmSchema keeps ['flow', 'approval'], and the http / notify / connector_action descriptors (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.

  • Updated dependencies [a46c017]

  • Updated dependencies [b990b89]

  • Updated dependencies [99111ec]

  • Updated dependencies [d5a8161]

  • Updated dependencies [5cf1f1b]

  • Updated dependencies [9ef89d4]

  • Updated dependencies [3306d2f]

  • Updated dependencies [c262301]

  • Updated dependencies [bc44195]

  • Updated dependencies [9e2e229]

    • @objectstack/spec@8.0.0
    • @objectstack/core@8.0.0
    • @objectstack/formula@8.0.0

7.9.0

Patch Changes

  • @objectstack/spec@7.9.0
  • @objectstack/core@7.9.0
  • @objectstack/formula@7.9.0

7.8.0

Patch Changes

  • Updated dependencies [06f2bbb]
  • Updated dependencies [f01f9fa]
  • Updated dependencies [36719db]
  • Updated dependencies [424ab26]
    • @objectstack/spec@7.8.0
    • @objectstack/formula@7.8.0
    • @objectstack/core@7.8.0

7.7.0

Patch Changes

  • Updated dependencies [b391955]
  • Updated dependencies [f06b64e]
  • Updated dependencies [825ab06]
  • Updated dependencies [023bf93]
    • @objectstack/spec@7.7.0
    • @objectstack/formula@7.7.0
    • @objectstack/core@7.7.0

7.6.0

Minor Changes

  • 955d4c8: ADR-0018 M3: unified http / notify executors 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 Flow http node through it — closing the "http_request is a bare fetch() with no retry" gap. The five divergent outbound verbs collapse onto canonical http / notify.

    @objectstack/service-messaging (additive):

    • IHttpOutbox / HttpDelivery generic raw-callout shape (source / refId / dedupKey / label / signingSecret), SqlHttpOutbox over a new sys_http_delivery object, MemoryHttpOutbox, HttpDispatcher (per-partition cluster lock, claim/ack/retry/dead-letter), and a shared sendOnce + 7-step jittered retry schedule.
    • MessagingService gains setHttpOutbox() / isHttpDeliveryReady() / enqueueHttp(); the plugin wires the outbox + dispatcher at kernel:ready.

    @objectstack/service-automation:

    • Canonical http executor — durable: true enqueues onto the messaging HTTP outbox (retry/dead-letter); otherwise an inline fetch() preserving http_request's request/response semantics.
    • engine.registerNodeAlias() — registers a delegating executor + a deprecated / aliasOf descriptor. http_request / http_call / webhook are now deprecated aliases of http; existing flows keep running.
    • notify descriptor marked needsOutbox (its delivery is outbox-backed).

    @objectstack/spec: flow.zod adds http to the builtin node-type seed set.

    plugin-webhooks cut-over to the shared outbox is a deliberate follow-up.

  • 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 to false and made a flow "fire" with no effect:

    • service-automation: flow evaluateCondition no longer swallows CEL failures to false — it throws an attributed, corrective error; and registerFlow now 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.
    • cli: objectstack compile validates 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_expression tool so authoring agents self-correct before committing.
    • spec: fix the FlowSchema JSDoc example that taught the bad condition: "{amount} < 500" single-brace form.
  • cf03ef2: Persist suspended flow runs so a durable pause survives a process restart (#1518).

    service-automation kept suspended runs in an in-memory Map only, so a flow paused at an approval / wait / screen node could never be resumed after the process restarted — a hard blocker on hibernating/serverless hosts (e.g. the Cloudflare Workers control plane), where the approval record persists but resume(runId) had nothing to continue.

    The engine now backs that map with a pluggable SuspendedRunStore (ADR-0019):

    • SuspendedRunStore interface + two implementations — InMemorySuspendedRunStore (the default; JSON round-trips so it faithfully mirrors a DB boundary) and ObjectStoreSuspendedRunStore, which persists to a new sys_automation_run system object via the ObjectQL engine. AutomationServicePlugin registers the object and auto-enables the DB-backed store when an ObjectQL engine is present (opt out with suspendedRunStore: 'memory').
    • Durable suspend/resume — a run is persisted on suspend and deleted on terminal completion. resume(runId) rehydrates from the store when the run is not in memory (cold boot), so a fully restarted kernel can continue from the paused node down the correct branch and run the downstream nodes. The resumable state (variables / steps / context / screen) round-trips through the store, including nested objects.
    • Idempotent resume — the suspension is consumed before downstream work runs, plus an in-process guard rejects a concurrent duplicate resume, so a repeated resume after a partial restart can't double-run side effects.
    • Run ids are now process-unique (random component) so they don't collide with a still-suspended run persisted by a previous process lifetime.

    New exports: SuspendedRun, SuspendedRunStore, StepLogEntry, InMemorySuspendedRunStore, ObjectStoreSuspendedRunStore, SuspendedRunStoreEngine, SysAutomationRun, plus AutomationEngine.setSuspendedRunStore() and listSuspendedRunsDurable(). Existing service-automation and plugin-approvals tests pass unchanged.

  • 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.ts defining the loop container (config.body), parallel block (config.branches[], implicit join), and try/catch/retry (config.try/config.catch/config.retry) configs, plus region well-formedness analysis (analyzeRegion, findRegionEntry) and validateControlFlow (single-entry/single-exit, acyclic; bounded loop).
    • engine: registerFlow() now rejects malformed control-flow regions before a flow can run; new AutomationEngine.runRegion() executes a body region in the enclosing variable scope without touching the shared DAG traversal.
    • loop executor: replaces the no-op loop stub 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 (no config.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).

  • f06a6a5: feat(automation): structured parallel block (ADR-0031, task 3)

    Implement engine execution for the parallel block — a structured construct with an implicit join (ADR-0031 §Decision 2). The parallel node declares N branch regions in config.branches[]; the executor runs them concurrently in the enclosing variable scope (via AutomationEngine.runRegion) and continues once when all branches complete — no author-visible split/join gateway.

    • New builtin/parallel-node.ts executor (registered as a built-in).
    • Branch failure fails the block (surfaced as a node failure → fault edge/error handling); durable pause inside a branch is a clear error.
    • Well-formedness (≥2 branches, single-entry/single-exit regions) is already enforced at registerFlow() by validateControlFlow (shipped with the loop container).

    Showcase FanOutNotifyFlow demonstrates the parallel block. Try/catch execution and BPMN interop mapping remain follow-ups (#1479 tasks 4–5).

  • 4ee139d: feat(automation): structured try/catch/retry block (ADR-0031, task 4)

    Implement engine execution for the try_catch construct — structured error handling (ADR-0031 §Decision 3). The node runs a protected try region; on failure it retries with exponential backoff (config.retry), and if it still fails the optional catch region runs with the caught error bound to config.errorVariable (default $error). Both regions execute in the enclosing variable scope via AutomationEngine.runRegion.

    • New builtin/try-catch-node.ts executor (registered as a built-in).
    • try success (incl. a successful retry) → node succeeds; catch handling a failure → node succeeds; no catch / failing catch → node fails to the flow's fault edge / error handling.
    • Well-formedness (single-entry/single-exit try/catch regions) is already enforced at registerFlow() by validateControlFlow (shipped with the loop container).

    Showcase ResilientSyncFlow demonstrates the construct. This completes the native control-flow execution trio (loop / parallel / try-catch); BPMN interop mapping remains a follow-up (#1479 task 5).

Patch Changes

  • Updated dependencies [955d4c8]
  • Updated dependencies [c4a4cbd]
  • Updated dependencies [b046ec2]
  • Updated dependencies [2170ad9]
  • Updated dependencies [02d6359]
  • Updated dependencies [7648242]
  • Updated dependencies [8fa1e7f]
  • Updated dependencies [55866f5]
  • Updated dependencies [60f9c45]
    • @objectstack/spec@7.6.0
    • @objectstack/formula@7.6.0
    • @objectstack/core@7.6.0

7.5.0

Minor Changes

  • 1560880: Implement the subflow node executor — invoke another flow as a reusable step.

    The designer offered a subflow node but the engine had no executor, so a flow using it couldn't run. subflow now:

    • resolves config.input (a {token} mapping) against the parent's variables,
    • runs config.flowName via engine.execute(...), and
    • writes the child's output back — under ${nodeId}.output, and under config.outputVariable as a bare variable when given.

    Scope (v1): synchronous subflows that run to completion. If the child suspends (a nested approval / screen / wait), the node fails with a clear message rather than silently dropping the run — nested durable pause is a deliberate follow-up. A depth guard (16) turns an accidental recursive cycle into a clean error instead of a stack overflow.

    A bare AutomationServicePlugin now ships 14 executors including subflow.

    Tests: subflow-node.test.ts — invoke + input-mapping + output capture, missing flowName, child-not-found, child-suspended, recursion guard. service-automation 118 passing. Worked examples added to the showcase: a reusable showcase_notify_owner subflow (template: true) invoked by showcase_task_done_notify_owner.

  • a2263e6: Implement the wait node executor — durable timer / signal pause.

    The flow designer offered a wait node but the engine had no executor for it, so a flow using it couldn't run. wait now suspends the run on entry (ADR-0019 durable pause, the same suspend/resume machinery as screen / approval) and resumes by one of two paths, per waitEventConfig.eventType:

    • timer — schedules a one-shot job (IJobService, { type: 'once', at }) that calls engine.resume(runId) when the ISO-8601 timerDuration elapses. With no job service the run still suspends and is resumable via an external resume(runId) (logged) — never silently no-ops or fails the flow.
    • signal / webhook / manual / condition — suspends with the signal name as the correlation key; an external producer resumes the run when the event arrives.

    Reads its run id from the engine-injected $runId variable (same mechanism the approval node uses). Adds a parseIsoDuration helper (PT1H, P3D, PT90M, P1DT12H, bare ms). Registered as a built-in node, so a bare AutomationServicePlugin now ships 13 executors including wait.

    Tests: wait-node.test.ts — duration parsing, suspend→resume traversal, one-shot job scheduling + handler-driven resume, named-signal suspend. service-automation 113 passing. A worked showcase_task_follow_up flow (wait → notify) demonstrates it end-to-end.

Patch Changes

  • @objectstack/spec@7.5.0
  • @objectstack/core@7.5.0
  • @objectstack/formula@7.5.0

7.4.1

Patch Changes

  • @objectstack/spec@7.4.1
  • @objectstack/core@7.4.1
  • @objectstack/formula@7.4.1

7.4.0

Minor Changes

  • 13632b1: ADR-0030 P0 (framework) — converge notifications onto a single ingress and the layered model. Every producer now publishes through NotificationService.emit(EmitInput); the in-app inbox is a materialization of delivery, not a row producers write.

    Single ingress (@objectstack/service-messaging) — breaking

    • MessagingService.emit takes the new EmitInput contract (topic / audience / payload / severity / dedupKey / source / actorId / organizationId / channels) instead of the flat Notification shape. It writes the L2 sys_notification event (idempotent on dedupKey), resolves the audience, then fans out; it returns { notificationId, deduped, deliveries, delivered, failed }.
    • New sys_notification_receipt object — the read-state spine (delivered|read|clicked|dismissed), keyed (notification_id, user_id, channel). The inbox channel writes a delivered receipt on materialization.
    • sys_inbox_message: adds notification_id / delivery_id, drops read (read-state moved to the receipt), adds the user mine list view.

    Event re-model (@objectstack/platform-objects) — breaking

    • sys_notification is re-modeled from a per-user inbox into the L2 event (topic, payload, severity, dedup_key, source_*, actor_id). Removes recipient_id / is_read / read_at / type / title / body / url / actor_name and the inbox actions/views. App-nav: the account inbox points at sys_inbox_message; Setup shows the notification event log.

    Producers routed through emit()

    • @objectstack/service-automation: the notify node maps its config to EmitInput.
    • @objectstack/plugin-audit: collaboration @mentioncollab.mention and assignment → collab.assignment (both with a dedupKey); no more direct sys_notification writes. Collaboration notifications now require MessagingServicePlugin (they degrade to a warn otherwise).

    Migration (@objectstack/metadata)

    • Idempotent migrateSysNotificationToEvent splits legacy sys_notification inbox rows into sys_inbox_message + receipts and rewrites the event row.

    Startup (@objectstack/cli, @objectstack/runtime)

    • messaging is now a foundational capability. On objectstack serve it is added to ALWAYS_ON_CAPABILITIES (every non-minimal preset starts it); on cloud per-project kernels the capability loader expands requires to add messaging whenever audit is present. This keeps collaboration @mention / assignment notifications (which now flow through the pipeline) working out of the box on both paths. --preset minimal opts out.

    The Console bell repoint (objectui) and phases P1–P3 are tracked in docs/handoff/adr-0030-notification-convergence.md.

  • 13d8653: Record-change flow trigger — auto-launch flows on data mutations.

    Completes the automation engine's FlowTrigger extension point so flows whose start node declares a record-change trigger (config: { objectName, triggerType: 'record-after-update', condition }) actually fire on the matching mutation. Previously the slot was dead — nothing called trigger.start — so such flows could only run via a manual engine.execute().

    Engine baseline (@objectstack/service-automation)

    • Redefines FlowTrigger around a parsed FlowTriggerBinding (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 (mirrors connector_actionconnector-rest).
    • Ordering-independent, bidirectional wiring: registerFlow/toggleFlow activate bindings; registerTrigger retro-binds already-registered flows (a trigger plugin wires up on kernel:ready, after flows are pulled in); unregisterFlow/unregisterTrigger/disable tear them down.
    • Centralized start-condition gate in execute(): the start node's condition (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, and previous into flow variables.
    • New getActiveTriggerBindings() getter + exports FlowTriggerBinding.

    Spec (@objectstack/spec)

    • Adds previous? to AutomationContext — 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-updateafterUpdate, etc.), builds an AutomationContext from 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 (mirrors plugin-audit).

    The schedule trigger (ticker/cron + sys_job lifecycle) is a follow-up.

  • ff3d006: Screen-flow runtime — interactive screen nodes (suspend → render → resume).

    A screen node that declares input fields now suspends the run on entry (reusing the ADR-0019 durable pause), surfaces a ScreenSpec describing the form, and resumes with the collected values applied as bare flow variables so downstream nodes read them via {var}. (waitForInput: false forces the old server pass-through.)

    • spec: AutomationResult.screen?: ScreenSpec, ResumeSignal.variables? (bare vars), IAutomationService.getSuspendedScreen?(runId).
    • service-automation: the screen executor builds the ScreenSpec and suspends when fields are present; the suspend/resume plumbing threads the screen through FlowSuspendSignalSuspendedRun → the paused result; resume() sets signal.variables as bare flow variables; getSuspendedScreen.
    • runtime: POST /api/v1/automation/:name/runs/:runId/resume (body { inputs }) and GET …/runs/:runId/screen, wired through both the dispatcher route table and handleAutomation.

    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 FlowRunner UI that renders these screens ships separately.

Patch Changes

  • a6d4cbb: Fix conditional & record-change flows silently skipping.

    Two bugs together caused every flow with a start-node / edge condition to silently skip (record-change triggers fired but the flow body never ran; audit-style previous.* gates and budget > 100000-style gates all evaluated to false):

    • service-automation — CEL engine unreachable in ESM. The condition evaluator loaded the formula engine via a CommonJS require('@objectstack/formula'). In the package's ESM build ("type": "module") that resolves to tsup's throwing __require stub, so every CEL evaluation threw and the swallowing catch returned false. Replaced with a static top-level import, which binds correctly in both the ESM and CJS builds.

    • objectql — prior record not exposed to update hooks. HookContext documents a previous snapshot for update/delete, but engine.update never populated it (the row it fetched for validation was a local var). Record-change conditions like status == "done" && previous.status != "done" therefore had no previous to read. The engine now attaches the pre-update record to hookContext.previous for single-id updates whenever a validation rule needs it or an afterUpdate hook is registered.

    Both paths are covered by new unit tests.

  • Updated dependencies [23c7107]

  • Updated dependencies [c72daad]

  • Updated dependencies [f115182]

  • Updated dependencies [2faf9f2]

  • Updated dependencies [2faf9f2]

  • Updated dependencies [2faf9f2]

  • Updated dependencies [58b450b]

  • Updated dependencies [82eb6cf]

  • Updated dependencies [13d8653]

  • Updated dependencies [ff3d006]

  • Updated dependencies [5e831de]

    • @objectstack/spec@7.4.0
    • @objectstack/core@7.4.0
    • @objectstack/formula@7.4.0

7.3.0

Patch Changes

  • Updated dependencies [5e7c554]
    • @objectstack/spec@7.3.0
    • @objectstack/core@7.3.0
    • @objectstack/formula@7.3.0

7.2.1

Patch Changes

  • @objectstack/spec@7.2.1
  • @objectstack/core@7.2.1
  • @objectstack/formula@7.2.1

7.2.0

Patch Changes

  • @objectstack/spec@7.2.0
  • @objectstack/core@7.2.0
  • @objectstack/formula@7.2.0

7.1.0

Patch Changes

  • Updated dependencies [47a92f4]
    • @objectstack/spec@7.1.0
    • @objectstack/core@7.1.0
    • @objectstack/formula@7.1.0

7.0.0

Patch Changes

  • Updated dependencies [74470ad]
  • Updated dependencies [d29617e]
  • Updated dependencies [dc72172]
    • @objectstack/spec@7.0.0
    • @objectstack/core@7.0.0
    • @objectstack/formula@7.0.0

6.9.0

Patch Changes

  • @objectstack/spec@6.9.0
  • @objectstack/core@6.9.0
  • @objectstack/formula@6.9.0

6.8.1

Patch Changes

  • @objectstack/spec@6.8.1
  • @objectstack/core@6.8.1
  • @objectstack/formula@6.8.1

6.8.0

Patch Changes

  • Updated dependencies [6e88f77]
  • Updated dependencies [c8b9f57]
    • @objectstack/spec@6.8.0
    • @objectstack/core@6.8.0
    • @objectstack/formula@6.8.0

6.7.1

Patch Changes

  • @objectstack/spec@6.7.1
  • @objectstack/core@6.7.1
  • @objectstack/formula@6.7.1

6.7.0

Patch Changes

  • Updated dependencies [430067b]
  • Updated dependencies [4f9e9d4]
    • @objectstack/spec@6.7.0
    • @objectstack/core@6.7.0
    • @objectstack/formula@6.7.0

6.6.0

Patch Changes

  • Updated dependencies [a49cfc2]
    • @objectstack/spec@6.6.0
    • @objectstack/core@6.6.0
    • @objectstack/formula@6.6.0

6.5.1

Patch Changes

  • @objectstack/spec@6.5.1
  • @objectstack/core@6.5.1
  • @objectstack/formula@6.5.1

6.5.0

Patch Changes

  • @objectstack/spec@6.5.0
  • @objectstack/core@6.5.0
  • @objectstack/formula@6.5.0

6.4.0

Patch Changes

  • Updated dependencies [f8651cc]
  • Updated dependencies [f8651cc]
  • Updated dependencies [0bf6f9a]
    • @objectstack/spec@6.4.0
    • @objectstack/core@6.4.0
    • @objectstack/formula@6.4.0

6.3.0

Patch Changes

  • @objectstack/spec@6.3.0
  • @objectstack/core@6.3.0
  • @objectstack/formula@6.3.0

6.2.0

Patch Changes

  • Updated dependencies [b4c74a9]
    • @objectstack/spec@6.2.0
    • @objectstack/core@6.2.0
    • @objectstack/formula@6.2.0

6.1.1

Patch Changes

  • @objectstack/spec@6.1.1
  • @objectstack/core@6.1.1
  • @objectstack/formula@6.1.1

6.1.0

Patch Changes

  • Updated dependencies [93c0589]
    • @objectstack/spec@6.1.0
    • @objectstack/core@6.1.0
    • @objectstack/formula@6.1.0

6.0.0

Patch Changes

  • Updated dependencies [629a716]
  • Updated dependencies [dbc4f7d]
  • Updated dependencies [944f187]
    • @objectstack/spec@6.0.0
    • @objectstack/core@6.0.0
    • @objectstack/formula@6.0.0

5.2.0

Patch Changes

  • Updated dependencies [bab2b20]
  • Updated dependencies [fa011d8]
  • Updated dependencies [b806f58]
    • @objectstack/spec@5.2.0
    • @objectstack/core@5.2.0
    • @objectstack/formula@5.2.0

5.1.0

Patch Changes

  • Updated dependencies [75f4ee6]
  • Updated dependencies [823d559]
    • @objectstack/spec@5.1.0
    • @objectstack/core@5.1.0
    • @objectstack/formula@5.1.0

5.0.0

Patch Changes

  • Updated dependencies [2f9073a]
    • @objectstack/spec@5.0.0
    • @objectstack/core@5.0.0
    • @objectstack/formula@5.0.0

4.2.0

Patch Changes

  • Updated dependencies [2869891]
    • @objectstack/spec@4.2.0
    • @objectstack/core@4.2.0
    • @objectstack/formula@4.2.0

4.1.1

Patch Changes

  • @objectstack/spec@4.1.1
  • @objectstack/core@4.1.1
  • @objectstack/formula@4.1.1

4.1.0

Patch Changes

  • Updated dependencies [2108c30]
  • Updated dependencies [23db640]
    • @objectstack/spec@4.1.0
    • @objectstack/core@4.1.0
    • @objectstack/formula@4.1.0

4.0.5

Patch Changes

  • 15e0df6: chore: unify all package versions to a single patch release
  • Updated dependencies [15e0df6]
    • @objectstack/spec@4.0.5
    • @objectstack/core@4.0.5
    • @objectstack/formula@4.0.5

4.0.4

Patch Changes

  • Updated dependencies [326b66b]
    • @objectstack/spec@4.0.4
    • @objectstack/core@4.0.4

4.0.3

Patch Changes

  • @objectstack/spec@4.0.3
  • @objectstack/core@4.0.3

4.0.2

Patch Changes

  • Updated dependencies [5f659e9]
    • @objectstack/spec@4.0.2
    • @objectstack/core@4.0.2

4.0.0

Patch Changes

  • Updated dependencies [f08ffc3]
  • Updated dependencies [e0b0a78]
    • @objectstack/spec@4.0.0
    • @objectstack/core@4.0.0

3.3.1

Patch Changes

  • @objectstack/spec@3.3.1
  • @objectstack/core@3.3.1

3.3.0

Patch Changes

  • @objectstack/spec@3.3.0
  • @objectstack/core@3.3.0

3.2.9

Patch Changes

  • @objectstack/spec@3.2.9
  • @objectstack/core@3.2.9

3.2.8

Patch Changes

  • @objectstack/spec@3.2.8
  • @objectstack/core@3.2.8

3.2.7

Patch Changes

  • @objectstack/spec@3.2.7
  • @objectstack/core@3.2.7

3.2.6

Patch Changes

  • @objectstack/spec@3.2.6
  • @objectstack/core@3.2.6

3.2.5

Patch Changes

  • @objectstack/spec@3.2.5
  • @objectstack/core@3.2.5

3.2.4

Patch Changes

  • @objectstack/spec@3.2.4
  • @objectstack/core@3.2.4

3.2.3

Patch Changes

  • @objectstack/spec@3.2.3
  • @objectstack/core@3.2.3

3.2.2

Patch Changes

  • Updated dependencies [46defbb]
    • @objectstack/spec@3.2.2
    • @objectstack/core@3.2.2

3.2.1

Patch Changes

  • Updated dependencies [850b546]
    • @objectstack/spec@3.2.1
    • @objectstack/core@3.2.1

3.2.0

Patch Changes

  • Updated dependencies [5901c29]
    • @objectstack/spec@3.2.0
    • @objectstack/core@3.2.0

3.1.1

Patch Changes

  • Updated dependencies [953d667]
    • @objectstack/spec@3.1.1
    • @objectstack/core@3.1.1

3.1.0

Patch Changes

  • Updated dependencies [0088830]
    • @objectstack/spec@3.1.0
    • @objectstack/core@3.1.0

3.0.11

Patch Changes

  • Updated dependencies [92d9d99]
    • @objectstack/spec@3.0.11
    • @objectstack/core@3.0.11

3.0.10

Patch Changes

  • Updated dependencies [d1e5d31]
    • @objectstack/spec@3.0.10
    • @objectstack/core@3.0.10

3.0.9

Patch Changes

  • Updated dependencies [15e0df6]
    • @objectstack/spec@3.0.9
    • @objectstack/core@3.0.9

3.0.8

Patch Changes

  • Updated dependencies [5a968a2]
    • @objectstack/spec@3.0.8
    • @objectstack/core@3.0.8