-
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), namingrunAs:'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:
isSystemdoes not suppress trigger dispatch — onlyskipTriggersdoes, and exactly three first-party paths set it — so every plugin/service system write, the approvals status mirror, and arunAs:'system'flow's own data node dispatched record-change flows withuserId: 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'sisSystemshort-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 writesys_user_positiontoday; asisSystemit 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 (runAsis 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 ownisSystembranch. TheflowRunIdexemption itself stays live and load-bearing for what #3703 built it for — arunAs:'user'run that does have a user — where the exemption is still provenance rather than privilege.Also in this change:
flow-schedule-runas-unscoped→flow-runas-unscoped, and it now fails the build. It read as a gate and behaved as a comment —os compiledocumented 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 coverrecord_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 vectorskipTriggersexists 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'sautomationprincipal: when that lands, the refusal point becomes the place that resolves it.
-
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/resumeforwarded a caller-supplied{ inputs, output, branchLabel }straight intoAutomationEngine.resume, andresumeInternalvalidated 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, nosys_approval_actionrow and no status mirror — thesys_approval_requestrow 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 engineresume"), 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 pausedscreennode. 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.approvaldeclares'service'.- The engine refuses a
'service'suspension unless the signal carriesRESUME_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.ApprovalServicestamps 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.
screenandwaitpauses 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 stampRESUME_AUTHORITY_SERVICEon 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:
expressionapprovers. 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) andvars.*(flow variables, incl. upstream node outputs).recordand bare field names are rejected before evaluation — on this platformrecordalways means "the record at event time", which is ambiguous at an approval node — with error messages that prescribe the correct spelling. The optionalresolveAs: 'user' | 'department' | 'position' | 'team're-expands each resolved id through the same graph lookups the static types use; withbehavior: 'per_group'each intermediate value (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.onEmptyApproverspolicy. 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), orauto_approve(skip the request, continue down theapproveedge withoutput.autoApproved = true). To support auto-approve, the automation engine now honoursNodeExecutionResult.branchLabelon 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 readvars.<nodeId>.picked_departments, closing "the previous approver picks the next step's approvers" without a record-field detour. Undeclared keys reject the decision;decision/requestIdare 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:
collectCelRootIdentifiersis exported from@objectstack/formula(shared by the newos lintrules 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
faultedge must not switch off a guardrail (#3863)A
faultedge routes a failed node to a handler instead of aborting the run. That is the right primitive for the world not cooperating — anhttpnode 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
faultedge to adelete_recordwhose 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 reportedsuccess: 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.errorClassis'runtime'(default — every existing executor keeps its current routing) or'guard'. Guard-class failures are never routed: they stay fatal with or without afaultedge, and the run fails with the guard's own message. Thrown guards are covered too —UnscopedRunDataAccessErroris branded via a sharedguard-refusalmodule, so the engine's catch path cannot become the bypass the return path no longer is.Marked as guard-class: the three
resolveNodeFilterrefusals (#3810), the fourobjectName requiredrefusals, andUnscopedRunDataAccessError(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}.$errornames 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 —$erroris unchanged.- Fault edges are documented for the first time (
content/docs/automation/flows.mdxand 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 todeleteManyis every row in the table.So one mistyped field name in a
delete_recordnode 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_recordnow 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.filteris where two{…}dialects meet — the flow template dialect ({record.owner}) and the filter placeholder dialect ({current_year_start},{current_user_id}, resolved byresolveFilterTokens()). 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()reachedfind/findOne/count/aggregateonly. So the SAME filter selected different rows depending on the verb:find({ owner: '{current_user_id}' })matched the signed-in user's rows, whileupdate/deletecompared 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.updateanddeletenow resolve too, BEFORE the by-id fast path claims a scalarwhere.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 —
resolveRunDataContexthonorsrunAs, so arunAs:'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 (newAutomationEngine.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
multiplelookup 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 validatelint ruleflow-template-lookup-traversal(#3426/#3472) is now suppressed for a relation once the flow declares it inconfig.expand. -
7687f7b: fix(automation): a screen field's
visibleWhenreaches the client (#3528)visibleWhenhas been on thescreennode'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.ScreenFieldSpeccarriedname/label/type/required/options/defaultValue/placeholderand 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
requiredis 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.visibleWhenis now part of the contract, documented as client-evaluated bare CEL over the screen's own field names, with therequired-must-follow-visibility rule stated where implementors will read it.- The
screenexecutor 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_recordused to report an unconditionalsuccesseven when the data layer legally stripped the requested write fields — staticreadonly(#2948) or a TRUEreadonlyWhenpredicate (#3042). The only trace was a server-side logger warn, invisible in the flow run trace: an author saw a clean 3mssuccesswhile the DB truth never changed (how #3356's approval stage write-backs failed unnoticed).- spec: new
DroppedFieldsEventSchema/DroppedFieldsEvent({ object, fields, reason: 'readonly' | 'readonly_when' }) indata/data-engine.zod.ts, and aWriteObservabilityOptions(onFieldsDroppedlistener) mixin onIDataEngine.insert/updateoption params incontracts/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 throughoptions.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:
NodeExecutionResultandStepLogEntrygain advisorywarnings?: string[];update_record/create_recordattach one warning per strip event naming the dropped fields, plus a structureddroppedFieldsoutput ({<nodeId>.droppedFields}) for downstream nodes.successsemantics are unchanged — stripping stays legal, it just is no longer silent.
- spec: new
-
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 manualresumewith no decision, or a node that writes the record between opening the approval and the decision — died on its ownRECORD_LOCKED, and the record stayed locked behind the dead run. Recovery existed (#3424 lets an adminrecall/rejectto 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
flowRunIdonto the run context at setup, alongsiderunAs, and it travels with every data node's ObjectQL context intoctx.provenance. The lock hook exempts a write whoseflowRunIdmatches the pending request'sflow_run_id. It is keyed on run identity rather than elevation on purpose: arunAs:'user'run stays fully RLS-scoped while it writes.flowRunIdis pure provenance — server-constructed likeisSystem, 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 asrecalled— releasing the lock — and audited under the reserved actorsystem:dead-runwith 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, agetRunthat 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 aspausedforever, 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 noflowRunIdand 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 effectiverunAs:'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 ownRECORD_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.
resolveRunDataContextreturns{ flowRunId }— nouserId, nopositions, nopermissions, not evenisSystem: false. Every principal gate keys on one of those fields (the elevation short-circuit onisSystem, the ADR-0103 engine-owned write guard and the ADR-0090 D12 delegated-admin gate onuserId, 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 theflow-schedule-runas-unscopedbuild-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.sessionanswers 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 intosessionwould 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.flowRunIdsays what produced the write; the approvals lock reads it there.Also relaxes
BaseEngineOptionsSchema.contextto a partial envelope (ExecutionContextInput).positions/permissions/isSystemcarry 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, readctx.provenance.flowRunIdinstead — 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 nouserId, 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 declarerunAs: '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.actorIdarrives 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:slaandsystem:dead-runare reserved audit actors, not users, and presenting one as a user would put a non-user inupdated_byand in every downstream flow's identity. A flow that wants to react to those declaresrunAs:'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
tenantIdis 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'stenantIdfrom the resolved user's grants.
Visible change: the mirrored record's
updated_bynow names the acting user instead of retaining its previous value — ObjectQL's audit stamping is gated on the write context'suserIdalone, andisSystembuys no exemption. That is the attribution this fix is for: the approver who set the record toapprovedis now its last modifier. - The identity is the authenticated principal, never the request body's
-
9dcc0ae: fix(automation): array-form flow
triggerTypefails loudly instead of silently never firing (#3481)An array
triggerTypeon 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-writecreate-OR-update union exist — see #3457), but nothing said so:defineFlowpassed the array (start-nodeconfigis an open record), the engine'stypeof === '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 sametypeofnarrowing 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 arraytriggerTypecontaining anyrecord-*element now yields aflow-trigger-unknown-eventwarning atos validatetime, steering torecord-after-write(for created-or-updated) or one flow per event. - engine (
resolveTriggerBinding): such an array is routed to therecord_changetrigger — 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 atrecord-after-writeand #3457) rather than the generic unknown-token line.
- lint (
-
7ef20d0: feat(cli,automation): catch
label: 'error'written wheretype: '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;labelis 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
traverseNextruns every unconditional out-edge in parallel. The handler fires on every successful run ofcharge_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/approvalexecutor returns abranchLabeland 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 typedfault. 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 weretype: '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
faultedge handles one node, whileerrorHandling.retryreplays 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 ofexecuteNode); 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
labeldoes 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/specfor a while (date-macros.zod.ts,context-tokens.zod.ts) andobjectstack buildrejects 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 path —
find/findOne/count/aggregate, so REST queries, related lists, saved-view filters and flowfind_recordsall 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'sruntimeFilter, measure-scoped filters, and time-dimensiondateRanges. This path needs its own call:NativeSQLStrategycompiles raw SQL and binds comparands directly, so a dashboard widget never passes throughengine.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}readsExecutionContext.tenantId;{current_user_id}readsuserId. A request carrying neither now throws instead of resolving tonull— a null comparand degrades toIS NULLon 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 whatobjectstack buildalready 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:
notifyno longer sends the six-character string"undefined"as an audience member.to: ['{record.owner.manager}']walks.manageron a scalar foreign-key id, resolves to nothing, andString(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'sconfig.expand(#3475), which does hydrate the relation. - ObjectQL read path —
-
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 ofinterpolateStringcoerced every value withString(), andnotify-nodedid the same for a sole{$error}token.- New shared
stringifyForTemplatehelper (builtin/template.ts): objects and arrays are JSON-serialized (so the text stays legible and still carries the message), primitives pass through,null/undefinedrender as ''. interpolateString's embedded-substitution branch andnotify-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).
- New shared
-
5602211: fix(automation): close the default-routable footgun on refuse-to-execute guards (#3863)
#3881 stopped a
faultedge from swallowing a guard refusal, keyed onNodeExecutionResult.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 asguardturns 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:
httpwith nourlsubflowwith noconfig.flowName, andsubflowexceeding max nesting depth (a recursive graph nests exactly as deep next run)mapwith noconfig.flowNameconnector_actionwith noconnectorId/actionId
The seven
crud-nodesguards 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
errorClassrequired 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
previousinto the flow condition scope only when it was truthy, so on a record insert (record-after-create, and the create leg ofrecord-after-write)previouswas an unknown CEL variable. Any reference to it — including the documentedprevious == nullcreate-discrimination — threwcondition failed to evaluate as CEL: Unknown variable: previous, failing the whole start condition and dropping the run.previousis now always bound, tonullwhen there is no prior row. Soprevious == nullis the create leg andprevious != null/previous.<field>the update leg — the pattern therecord-after-writedocs and the Studio flow designer advertise. Update-triggered flows are unaffected (previouswas, 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:.resumeInternalhandles the two linked-run correlations oppositely — asubflow:pause delegates the signal to the child, amap:pause re-runs the map node — and the gate followed only the first. So a run parked on amapnode was judged onmapitself (resumeAuthority: 'any') and let through even while the item it was waiting on sat on anapproval.mapis the batch-approval shape, and the map parent's run id is the one a launcher holds. Since$mapState.startedis 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
inputscould 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 fromGET /automation/:name. The same reached$runId, whichapproval/waitnodes use to correlate external state back to a run.POST /automation/:name/runs/:runId/resumenow answers 400 wheninputsnames 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 likecollect.noteare unaffected. If you were driving a batch- approvalmapby 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 —
outputreopened the holeinputshad just closed (#3879)#3853 guarded
signal.variablesat the route. That closed one of two equivalent paths into the same variable map and left the other open:signal.outputkeys are merged under${run.nodeId}.${key}, and for a run parked on amapnoderun.nodeIdis the map node — sowrites exactly the
<mapNodeId>.$mapItemDonetheinputsguard 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
approvalwas 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:
applyResumeSignalis 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.
resumereturns{ 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.codegains'invalid_signal'alongside'forbidden'— aswitchover 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
-
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(systemelevates), but therunAs:'user'credential propagation was hollow. A record-change-triggeredrunAs:'user'flow ran its data nodes (update_record, …) with a zero-grant principal — only themember/everyonebaseline — even when the triggering user was fully authorized. Two faces by object config: aprivateobject 403'd the in-flow write (not permitted for positions [org_member, everyone]— the user's permission sets were invisible); apublic_read_writeobject let the write through but silently stripped readonly/FLS-gated fields. The root cause: the ObjectQL record-change hook session carries only auserId— 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/corefactors the userId-driven core ofresolveAuthzContextinto a new exportedresolveUserAuthzGrants(ql, userId, opts)— the single place that readssys_member/sys_user_position/sys_*_permission_setand 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-automationgainsAutomationEngine.setUserGrantsResolver, wired by the plugin toresolveUserAuthzGrantsover the objectql/data engine. For arunAs:'user'run whose trigger left the authz envelope unresolved (nopermissions), 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 carrypermissionsare 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-changestops forwarding the misleading half-populatedpositions(empty in practice, and neverpermissions) from the hook session; it forwardsuserId+ 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.
- Updated dependencies [9e45b63]
- Updated dependencies [b20201f]
- @objectstack/spec@16.1.0
- @objectstack/core@16.1.0
- @objectstack/formula@16.1.0
-
780b4b5: feat(automation): schema-aware flow-condition validation at registration (#1928)
registerFlownow runs the same schema-aware condition checks asobjectstack 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 (mirrorssetFunctionResolver);AutomationServicePluginwires it toobjectql.registry.getObjectinstart(), 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
flattenedscope (flow variables staydynand are never flagged; equality is runtime-safe).
Builds on the tier-4 type-soundness check in
@objectstack/formula/@objectstack/lint(#1928). - New
-
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, missingrequires: ['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 (requiresmissing), 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).
RecordChangeTriggerprobes object existence at bind time and warns when a flow'sobjectNamematches no registered object (exact-name matching), instead of silently arming a hook that can never fire.kernel:bootstrappedbinding audit in the automation plugin: warns per enabled-but-unbound triggered flow with the reason, and reports registered/bound/draft counts (AutomationEngine.getTriggerBindingAudit(), extendedgetFlowRuntimeStates()withstatus/triggerType/object).os validateflow-wiring advisories (@objectstack/lintvalidateFlowTriggerReadiness): warns when a record-triggered flow targets an object the stack does not define, and when an auto-triggered flow's status isdraft(authored or defaulted — draft flows still fire; declareactiveorobsolete).- Removed leftover boot-debug writes (
registerApp/AppPlugin/StandaloneStack/AuditPluginstderr noise) that previous debugging of this same silence had left behind.
- Startup banner
-
1e145eb: fix(automation): region-aware run-history compaction keeps loop containers + early failures (#3234)
compactStepsForHistorybounded a terminal run's persisted step log to the lastMAX_PERSISTED_HISTORY_STEPSentries with a plain tail-slice. With the ADR-0031 structured-region step logs (#1505) a singleloopcan emititerations × body-stepsentries, so the tail-slice dropped theloop/parallel/try_catchcontainer step (it precedes all its body steps) and every early iteration — leavinggetRun/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 atmaxsosteps_jsonstays 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 arecord_changeflow gated on a date-equality condition likeend_date == daysFromNow(60). That predicate is only evaluated when the record happens to change, so it fires only if a record is edited on exactly the threshold day — i.e. almost never, unattended. The robust alternative was a hand-written cron + range query that every author re-implemented (contractsrenewal_alert, hrdocument_expiring_soon, procurementpo_overdue, …).A flow's start node can now declare a
timeRelativedescriptor instead:config: { timeRelative: { object: 'contracts', dateField: 'end_date', offsetDays: [60, 30, 7], // T-minus reminders — fires on each threshold day // — or — withinDays: 30 // "expiring soon" range; negative = overdue lookback filter: { status: 'active' }, // optional, ANDed with the date window }, schedule: { type: 'cron', expression: '0 8 * * *' }, // optional; defaults to daily 08:00 UTC }
The new
time_relativetrigger (shipped in@objectstack/trigger-scheduleasTimeRelativeTriggerPlugin) sweeps the object on that schedule and launches the flow once per matching record, with the record on the automation context — so the start-nodeconditiongate and{record.<field>}interpolation work exactly as for a record-change flow. Because the window is evaluated every day, a threshold is never missed regardless of when the record last changed. The discovery query runs as a system operation (RLS-bypassing) and is capped (maxRecords, default 1000) so a mis-scoped window can't fan out unboundedly; per-record failures are isolated so one bad row never aborts the sweep.The automation engine routes a start node carrying
config.timeRelativeto thetime_relativetrigger (ahead of the plainscheduletrigger, whose behavior is unchanged), andos validategains readiness checks for the new descriptor (unknown swept object, ambiguous draft status). New authorable spec key:TimeRelativeTriggerSchema(@objectstack/spec/automation).
-
22013aa: Split the overloaded
managedBy: 'system'bucket into engine-owned vs. admin-writable, and enforce engine-owned writes (ADR-0103, #3220). Thesystembucket conflated two incompatible write policies: rows a platform service owns end to end (never user-written), and platform-defined schema whose rows are legitimately admin/user-writable. It carried the same all-false affordance row asbetter-auth/append-onlybut, unlikebetter-auth, had no engine enforcement — a wildcard admin could raw-write these rows through the generic data API (ADR-0049 gap).Rather than add a new
managedByenum value (which would fall through to fully-editableplatformdefaults on already-deployed Console clients), the write policy is now the resolved affordance (resolveCrudAffordances= bucket default +userActions), and engine-owned is defined as asystem/append-onlyobject that grants no write:- Writable set declares
userActions— the RBAC link tables (sys_user_position,sys_user_permission_set,sys_position_permission_set),sys_user_preference,sys_approval_delegation, and the messaging config grids (sys_notification_preference/…_subscription/…_template) now declareuserActions: { create, edit, delete: true }. The affordance is a declaration only — theDelegatedAdminGate/ RLS / permission sets remain the authz. - Engine-owned objects locked to reads —
apiMethods: ['get','list']added where absent (jobs, notifications, approval request/approver/token/action,sys_record_share,sys_automation_run, mail/settings/secret audit, the messaging delivery pipeline).sys_secretis explicitly read-locked (an emptyapiMethodsarray fails open). sys_import_jobstays engine-owned: the REST import route now writes its job rowsisSystem-elevated (attribution preserved via the explicitcreated_bystamp) and the object is locked to['get','list'].- New engine write guard (
assertEngineOwnedWriteAllowed, plugin-security) fail-closed rejects user-context generic writes to engine-ownedsystem/append-onlyobjects, keyed off the resolved affordance;isSystemand context-less engine/service writes bypass by construction. Wired into the security middleware alongside the other data-layer gates. reconcileManagedApiMethods(objectql registry) now runs for every managed bucket, not justbetter-auth: any advertised write verb an object's resolved affordances forbid is stripped at registration with a warning (the drift backstop, ADR-0049)./me/permissionsclamp (plugin-hono-server) now clampssystem/append-onlyas well asbetter-auth, so the client hint reflectspermission ∩ guard.
Potentially breaking: a downstream/third-party
systemobject that advertised generic write verbs relying on today's fail-open behaviour will have those verbs stripped (with a warning) and user-context generic writes to it rejected. DeclareuserActionsopening the verbs the object legitimately takes from a user context.better-authkeeps plugin-auth's identity write guard unchanged; the row-levelmanaged_byprovenance vocabulary (ADR-0066) is a different axis and is untouched. - Writable set declares
-
02eafa5: test(automation): end-to-end coverage for the #1928 object-schema resolver wiring
Adds a kernel-level integration test proving
AutomationServicePluginbridges the engine's object-schema resolver to the liveobjectql.registry.getObjectatstart()(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, andscreennodes shipped noconfigSchema, 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 asxRef, the screen repeater'svisibleWhenasxExpression: 'expression', and the free-form maps (fields/filter/assignments/defaults) as JSON-Schema open objects (additionalProperties: true, no fixedproperties) — 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-levelwaitEventConfig),script(actionType-conditional form),subflow(top-leveltimeoutMs).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
collectionconfig field as an interpolate() template so designer forms render it correctly (#3304)The flow designer generates a node's config form from its published
configSchema(ADR-0018). A string property can now carry anxExpression: 'expression' | 'template'marker — riding the same Zod.meta()→ JSON-Schema channel asxRef/xEnumDeprecated— that declares whether the string is bare CEL or aninterpolate()single-brace{var}template.The
loopnode'scollection(e.g.{tasks}) is a template, so it is now markedxExpression: 'template'on both the canonicalLoopConfigSchemaand the shipped descriptor'sconfigSchemaliteral (service-automation loop-node). Without the marker the designer renderedcollectionas plain text online while the offline hardcoded form rendered it as a mono expression editor, and the CEL brace-trap false-flagged{tasks}as a malformed condition. The marker closes that divergence — objectui #2670 Phase 3 (#2699) already consumes it.Additive and backward-compatible: an unknown
xExpressionvalue is ignored by the designer, and runtime behavior is unchanged. Filling the same marker in on the remaining node types (map/decision/script and the node types that publish noconfigSchemayet) is tracked as follow-up in #3304. -
62a2117: Split the overloaded
managedBy: 'system'bucket with an explicitengine-ownedvalue (ADR-0103 addendum, #3343). ADR-0103 deferred the enum split ("revisitable later as a rename") because a newmanagedByvalue would fall through to the fully-editableplatformdefault on deployed Console clients. Both reasons against it are now retired — the server-side write guard /apiMethodsreconciliation //me/permissionsclamp make that fallthrough cosmetic (the write is rejected regardless of what the client renders), and objectui#2712 closed the UI union — so v16 lands it, additively.- New enum value
engine-ownedwith the same all-locked default affordance row assystem(create/import/edit/delete: false,exportCsv: true). It joinsENGINE_OWNED_BUCKETS(the engine write guard) andGUARDED_WRITE_BUCKETS(the/me/permissionsclamp); the guard,reconcileManagedApiMethods, and the clamp mechanisms are unchanged —engine-ownedis an explicit member of the set they already covered by resolved affordance. - 20 objects relabelled
system → engine-owned— the ones the engine owns end to end and that declared no write-openinguserActions(the metadata store, jobs, approval runtime rows, sharing rows,sys_automation_run, the messaging delivery/receipt pipeline,sys_secret, settings). One-line, behaviour-identical per object. - 8 admin/user-writable objects keep
managedBy: 'system'(the RBAC link tables,sys_user_preference,sys_approval_delegation, the messaging config grids) —systemnow reads as "engine-managed schema, writable viauserActions".
Behaviour-, enforcement- and wire-identical: resolved affordances, the guard verdict, the 405
apiMethodsreconciliation, and the permissions clamp are the same before and after — this is a self-documenting relabel, not a policy change. No data migration (managedByis schema metadata) and no code branches on the'system'literal. Retiring the overloadedsystementirely (moving the 8 writable objects to a dedicated bucket) is a breaking rename deferred to v17. - New enum value
-
f8c1b69: feat(automation): publish a configSchema for the
mapnode (flow designer parity, #3304)The
map(sequential multi-instance) node shipped noconfigSchema, 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 structuredconfigSchemathat mirrors the objectui hardcodedmapfield group field-for-field —collection(markedxExpression: 'template', aninterpolate(){items}template, same asloop.collection),flowName+itemObjectas typed references (xRef), anditeratorVariable/outputVariableas plain text — so the online (schema-driven) and offline forms match.mapis the one previously-schemaless flow node whose fields are all scalars and typed references, so it maps cleanly through objectui'sjsonSchemaToFlowFieldswith zero regression. The remaining schemaless nodes lean on editor kinds the schema→fields adapter does not yet reproduce (keyValuemaps, the decision virtualtargetcolumn,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
-
b320158: feat(automation): publish configSchemas for the keyValue-capable nodes (flow designer parity, #3304)
The
assignment,create_record/update_record/delete_record/get_record, andscreennodes shipped noconfigSchema, 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 asxRef, the screen repeater'svisibleWhenasxExpression: 'expression', and the free-form maps (fields/filter/assignments/defaults) as JSON-Schema open objects (additionalProperties: true, no fixedproperties) — 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-levelwaitEventConfig),script(actionType-conditional form),subflow(top-leveltimeoutMs).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 explicitengine-ownedvalue (ADR-0103 addendum, #3343). ADR-0103 deferred the enum split ("revisitable later as a rename") because a newmanagedByvalue would fall through to the fully-editableplatformdefault on deployed Console clients. Both reasons against it are now retired — the server-side write guard /apiMethodsreconciliation //me/permissionsclamp make that fallthrough cosmetic (the write is rejected regardless of what the client renders), and objectui#2712 closed the UI union — so v16 lands it, additively.- New enum value
engine-ownedwith the same all-locked default affordance row assystem(create/import/edit/delete: false,exportCsv: true). It joinsENGINE_OWNED_BUCKETS(the engine write guard) andGUARDED_WRITE_BUCKETS(the/me/permissionsclamp); the guard,reconcileManagedApiMethods, and the clamp mechanisms are unchanged —engine-ownedis an explicit member of the set they already covered by resolved affordance. - 20 objects relabelled
system → engine-owned— the ones the engine owns end to end and that declared no write-openinguserActions(the metadata store, jobs, approval runtime rows, sharing rows,sys_automation_run, the messaging delivery/receipt pipeline,sys_secret, settings). One-line, behaviour-identical per object. - 8 admin/user-writable objects keep
managedBy: 'system'(the RBAC link tables,sys_user_preference,sys_approval_delegation, the messaging config grids) —systemnow reads as "engine-managed schema, writable viauserActions".
Behaviour-, enforcement- and wire-identical: resolved affordances, the guard verdict, the 405
apiMethodsreconciliation, and the permissions clamp are the same before and after — this is a self-documenting relabel, not a policy change. No data migration (managedByis schema metadata) and no code branches on the'system'literal. Retiring the overloadedsystementirely (moving the 8 writable objects to a dedicated bucket) is a breaking rename deferred to v17. - New enum value
-
f8c1b69: feat(automation): publish a configSchema for the
mapnode (flow designer parity, #3304)The
map(sequential multi-instance) node shipped noconfigSchema, 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 structuredconfigSchemathat mirrors the objectui hardcodedmapfield group field-for-field —collection(markedxExpression: 'template', aninterpolate(){items}template, same asloop.collection),flowName+itemObjectas typed references (xRef), anditeratorVariable/outputVariableas plain text — so the online (schema-driven) and offline forms match.mapis the one previously-schemaless flow node whose fields are all scalars and typed references, so it maps cleanly through objectui'sjsonSchemaToFlowFieldswith zero regression. The remaining schemaless nodes lean on editor kinds the schema→fields adapter does not yet reproduce (keyValuemaps, the decision virtualtargetcolumn,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
-
780b4b5: feat(automation): schema-aware flow-condition validation at registration (#1928)
registerFlownow runs the same schema-aware condition checks asobjectstack 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 (mirrorssetFunctionResolver);AutomationServicePluginwires it toobjectql.registry.getObjectinstart(), 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
flattenedscope (flow variables staydynand are never flagged; equality is runtime-safe).
Builds on the tier-4 type-soundness check in
@objectstack/formula/@objectstack/lint(#1928). - New
-
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, missingrequires: ['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 (requiresmissing), 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).
RecordChangeTriggerprobes object existence at bind time and warns when a flow'sobjectNamematches no registered object (exact-name matching), instead of silently arming a hook that can never fire.kernel:bootstrappedbinding audit in the automation plugin: warns per enabled-but-unbound triggered flow with the reason, and reports registered/bound/draft counts (AutomationEngine.getTriggerBindingAudit(), extendedgetFlowRuntimeStates()withstatus/triggerType/object).os validateflow-wiring advisories (@objectstack/lintvalidateFlowTriggerReadiness): warns when a record-triggered flow targets an object the stack does not define, and when an auto-triggered flow's status isdraft(authored or defaulted — draft flows still fire; declareactiveorobsolete).- Removed leftover boot-debug writes (
registerApp/AppPlugin/StandaloneStack/AuditPluginstderr noise) that previous debugging of this same silence had left behind.
- Startup banner
-
1e145eb: fix(automation): region-aware run-history compaction keeps loop containers + early failures (#3234)
compactStepsForHistorybounded a terminal run's persisted step log to the lastMAX_PERSISTED_HISTORY_STEPSentries with a plain tail-slice. With the ADR-0031 structured-region step logs (#1505) a singleloopcan emititerations × body-stepsentries, so the tail-slice dropped theloop/parallel/try_catchcontainer step (it precedes all its body steps) and every early iteration — leavinggetRun/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 atmaxsosteps_jsonstays 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 arecord_changeflow gated on a date-equality condition likeend_date == daysFromNow(60). That predicate is only evaluated when the record happens to change, so it fires only if a record is edited on exactly the threshold day — i.e. almost never, unattended. The robust alternative was a hand-written cron + range query that every author re-implemented (contractsrenewal_alert, hrdocument_expiring_soon, procurementpo_overdue, …).A flow's start node can now declare a
timeRelativedescriptor instead:config: { timeRelative: { object: 'contracts', dateField: 'end_date', offsetDays: [60, 30, 7], // T-minus reminders — fires on each threshold day // — or — withinDays: 30 // "expiring soon" range; negative = overdue lookback filter: { status: 'active' }, // optional, ANDed with the date window }, schedule: { type: 'cron', expression: '0 8 * * *' }, // optional; defaults to daily 08:00 UTC }
The new
time_relativetrigger (shipped in@objectstack/trigger-scheduleasTimeRelativeTriggerPlugin) sweeps the object on that schedule and launches the flow once per matching record, with the record on the automation context — so the start-nodeconditiongate and{record.<field>}interpolation work exactly as for a record-change flow. Because the window is evaluated every day, a threshold is never missed regardless of when the record last changed. The discovery query runs as a system operation (RLS-bypassing) and is capped (maxRecords, default 1000) so a mis-scoped window can't fan out unboundedly; per-record failures are isolated so one bad row never aborts the sweep.The automation engine routes a start node carrying
config.timeRelativeto thetime_relativetrigger (ahead of the plainscheduletrigger, whose behavior is unchanged), andos validategains readiness checks for the new descriptor (unknown swept object, ambiguous draft status). New authorable spec key:TimeRelativeTriggerSchema(@objectstack/spec/automation).
-
22013aa: Split the overloaded
managedBy: 'system'bucket into engine-owned vs. admin-writable, and enforce engine-owned writes (ADR-0103, #3220). Thesystembucket conflated two incompatible write policies: rows a platform service owns end to end (never user-written), and platform-defined schema whose rows are legitimately admin/user-writable. It carried the same all-false affordance row asbetter-auth/append-onlybut, unlikebetter-auth, had no engine enforcement — a wildcard admin could raw-write these rows through the generic data API (ADR-0049 gap).Rather than add a new
managedByenum value (which would fall through to fully-editableplatformdefaults on already-deployed Console clients), the write policy is now the resolved affordance (resolveCrudAffordances= bucket default +userActions), and engine-owned is defined as asystem/append-onlyobject that grants no write:- Writable set declares
userActions— the RBAC link tables (sys_user_position,sys_user_permission_set,sys_position_permission_set),sys_user_preference,sys_approval_delegation, and the messaging config grids (sys_notification_preference/…_subscription/…_template) now declareuserActions: { create, edit, delete: true }. The affordance is a declaration only — theDelegatedAdminGate/ RLS / permission sets remain the authz. - Engine-owned objects locked to reads —
apiMethods: ['get','list']added where absent (jobs, notifications, approval request/approver/token/action,sys_record_share,sys_automation_run, mail/settings/secret audit, the messaging delivery pipeline).sys_secretis explicitly read-locked (an emptyapiMethodsarray fails open). sys_import_jobstays engine-owned: the REST import route now writes its job rowsisSystem-elevated (attribution preserved via the explicitcreated_bystamp) and the object is locked to['get','list'].- New engine write guard (
assertEngineOwnedWriteAllowed, plugin-security) fail-closed rejects user-context generic writes to engine-ownedsystem/append-onlyobjects, keyed off the resolved affordance;isSystemand context-less engine/service writes bypass by construction. Wired into the security middleware alongside the other data-layer gates. reconcileManagedApiMethods(objectql registry) now runs for every managed bucket, not justbetter-auth: any advertised write verb an object's resolved affordances forbid is stripped at registration with a warning (the drift backstop, ADR-0049)./me/permissionsclamp (plugin-hono-server) now clampssystem/append-onlyas well asbetter-auth, so the client hint reflectspermission ∩ guard.
Potentially breaking: a downstream/third-party
systemobject that advertised generic write verbs relying on today's fail-open behaviour will have those verbs stripped (with a warning) and user-context generic writes to it rejected. DeclareuserActionsopening the verbs the object legitimately takes from a user context.better-authkeeps plugin-auth's identity write guard unchanged; the row-levelmanaged_byprovenance vocabulary (ADR-0066) is a different axis and is untouched. - Writable set declares
-
02eafa5: test(automation): end-to-end coverage for the #1928 object-schema resolver wiring
Adds a kernel-level integration test proving
AutomationServicePluginbridges the engine's object-schema resolver to the liveobjectql.registry.getObjectatstart()(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
collectionconfig field as an interpolate() template so designer forms render it correctly (#3304)The flow designer generates a node's config form from its published
configSchema(ADR-0018). A string property can now carry anxExpression: 'expression' | 'template'marker — riding the same Zod.meta()→ JSON-Schema channel asxRef/xEnumDeprecated— that declares whether the string is bare CEL or aninterpolate()single-brace{var}template.The
loopnode'scollection(e.g.{tasks}) is a template, so it is now markedxExpression: 'template'on both the canonicalLoopConfigSchemaand the shipped descriptor'sconfigSchemaliteral (service-automation loop-node). Without the marker the designer renderedcollectionas plain text online while the offline hardcoded form rendered it as a mono expression editor, and the CEL brace-trap false-flagged{tasks}as a malformed condition. The marker closes that divergence — objectui #2670 Phase 3 (#2699) already consumes it.Additive and backward-compatible: an unknown
xExpressionvalue is ignored by the designer, and runtime behavior is unchanged. Filling the same marker in on the remaining node types (map/decision/script and the node types that publish noconfigSchemayet) 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
- @objectstack/spec@15.1.1
- @objectstack/core@15.1.1
- @objectstack/formula@15.1.1
-
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 aprovider— 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 flowconnector_actioncalls it end-to-end.-
Schema (
@objectstack/spec).ConnectorSchemagainsprovider,providerConfig, andauth(acredentialRef-based instance-auth shape —ConnectorInstanceAuthSchema— that references credentials, never inlines them);authenticationnow defaults to{ type: 'none' }so a provider-bound instance need not author it (loosening — existing connectors are unaffected).DeclarativeConnectorEntrySchema(used bystack.zod.ts) rejects inline secrets, orphanproviderConfig/auth, and authoredactions/triggerson a provider-bound entry. A newintegration/connector-provider.tsdefines 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, resolvingauth.credentialRefvia a pluggableCredentialResolver(open-tier default: environment variables), and registering the materialized connector. Boot fails loudly for an unknown provider, invalidproviderConfig, an unresolvablecredentialRef, 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 ininit()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-openapiadds aConnectorOpenApiPlugin.
Open tier: static auth (
none/api-key/basic/bearer) withcredentialRefresolved from env vars. Managed vaulting, OAuth2 refresh, and per-tenant connection lifecycle remain the enterprise tier (ADR-0015) — an enterprise host injects a vault-backedCredentialResolverwith no change to the materialization path. -
-
f531a26: feat(connector-openapi): resolve
providerConfig.specfrom 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 landedopenapiprovider 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.ConnectorProviderContextgains an optional host-injectedloadPackageFile(relativePath)capability (pure type): reads a UTF-8 file resolved against the declaring stack/package root, confined to that root.undefinedon hosts without a filesystem. -
@objectstack/service-automation. NewpackageRootplugin option (the base for relative file refs; defaults toprocess.cwd()) and an exportedcreatePackageFileLoader(packageRoot)that implements the confinement guard — absolute paths and..-escaping paths are rejected — with lazynode:fs/node:pathimports 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 stringproviderConfig.specthat is not an http(s) URL is now read viactx.loadPackageFileand parsed as an OpenAPI JSON document (clear errors for missing/unreadable files, unparseable JSON, and hosts without package file access). -
@objectstack/cli.serve/devpass the project folder (theobjectstack.config.tsdirectory) as the automation service'spackageRoot, 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.materializeDeclaredConnectorsis now a reconcile run both at boot and onmetadata:reloaded:- Add newly-declared instances, tear down removed / newly-
enabled:falseones (calling theirclose, e.g. an MCP connection), and re-materialize only instances whose signature — a stable hash ofprovider+providerConfigauth+ 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 byGET /api/v1/automation/connectors) now carries anoriginfield ('plugin' | 'declarative'), so a designer can distinguish a materialized declarative instance from a plugin-registered connector. - Add newly-declared instances, tear down removed / newly-
-
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, unresolvablecredentialRef, name conflict) but wrong for operational ones: aprovider: '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(codeCONNECTOR_UPSTREAM_UNAVAILABLE, structural guardisConnectorUpstreamUnavailable) 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'+degradedReasonon theGET /connectorsdescriptor) so the instance is visible instead of silently missing — or, on a changed-config re-materialization, keeps the old connector serving. Aconnector_actionagainst 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 everymetadata:reloadedreconcile; recovery swaps the husk for the live connector atomically. Reconcile runs (boot / reload / retry timer) are now serialized. - connector-mcp: the
mcpprovider classifies connect /tools/listfailures 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.
- spec: a provider factory can now throw
-
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 viaengine.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 onmetadata:reloaded), declared connectors withactionsbut 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 gapconnector_actionwould 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 onstack.zod.ts(connectors:) andintegration/connector.zod.tsnow 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.
- Boot audit (service-automation). At
- 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
- 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
-
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/webhook→http。page-kind-jsx-to-html:页面kind: 'jsx'→'html'(ADR-0080 规范拼写)。flow-node-crud-filter-alias:CRUD 流程节点config.filters→config.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 退役路径示范:
filters→filter别名从service-automation执行器的readAliasedConfig兜底中删除,提升为上面这条声明式转换条目;执行器改为直接读取规范键cfg.filter。新增导出(纯增量,无破坏):
applyConversions、applyConversionsToFlow、collectConversionNotices、ALL_CONVERSIONS、CONVERSIONS_BY_MAJOR、CONVERSION_NOTICE_CODE、CONVERSION_CONFLICT_CODE,以及类型MetadataConversion、ConversionNotice、ConversionApplication、ConversionFixture、ConversionContext、ConversionConflictNotice、ConversionConflictDetail、ApplyConversionsOptions、NormalizeStackInputOptions。normalizeStackInput现接受可选第二参{ onConversionNotice, convert }(向后兼容)。
- 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
- Updated dependencies [d6a72eb]
- @objectstack/spec@14.7.0
- @objectstack/core@14.7.0
- @objectstack/formula@14.7.0
- Updated dependencies [609cb13]
- Updated dependencies [ce6d151]
- @objectstack/spec@14.6.0
- @objectstack/core@14.6.0
- @objectstack/formula@14.6.0
-
33ebd34: ADR-0057 (#2834):
retention.onlyWhenstatus 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 rotationstorage(shard DROPs ignore filters) orarchive(the Archiver moves rows by age alone). - objectql: the LifecycleService Reaper merges
onlyWheninto every retention delete, including tenant-override passes. - service-automation: the run-history age sweep is now declarative —
sys_automation_rundeclaresretention: { 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, theDEFAULT_RUN_HISTORY_RETENTION_DAYSexport, and therunHistoryRetentionDays/runHistorySweepMsplugin options are removed (launch-window breaking-as-minor). The write-time per-flow overflow cap (runHistoryMaxPerFlow) stays.
- spec:
-
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/NotificationRetentionand theretentionDays/retentionSweepMsoptions onJobServicePlugin/MessagingServicePluginare removed. The platform LifecycleService enforces the same windows from thelifecycledeclarations (sys_job_run30d, notification pipeline 90d); tune them at runtime via thelifecyclesettings namespace (retention_overrides, tenant-scoped). - Fix:
sys_automation_runno 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 devnow provisions a dedicatedtelemetrydatasource (<primary>.telemetry.db) for file-backed SQLite primaries, so lifecycle-classed system data stops sharing the business dev DB (OS_TELEMETRY_DB=0opts out;OS_TELEMETRY_DB=<path>opts in anywhere). Newos db cleanruns the one-timeVACUUMthat lets legacy files adoptauto_vacuum=INCREMENTALand reports reclaimed bytes. - Studio: the object metadata form exposes the
lifecycleblock (class + retention/TTL/rotation/archive/reclaim); metadata-forms i18n bundles regenerated with curated zh-CN translations.
- BREAKING (ships as minor per the launch-window convention):
-
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
-
7953832: ADR-0057 data lifecycle P1–P4 (#2786): platform-generated data is now bounded by construction.
- P1 — contract: new
lifecycleobject property (class: record | audit | telemetry | transient | event+retention/ttl/storage(rotation)/archive/reclaim), enforced by the platform-owned LifecycleService registered byObjectQLPlugin(default-on; disable viaOS_LIFECYCLE_DISABLED=1or pluginlifecycle.enabled=false). The Reaper batch-deletes rows pastretention.maxAge/ttlunder a system context and reclaims space (SqlDriver.reclaimSpace()→ SQLitePRAGMA incremental_vacuum). Non-recordclasses 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)DROPof 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
telemetryroutes telemetry/event/audit objects to it (opt-in by existence;transientdeliberately stays on the primary). Audit objects witharchivedeclared get retain → archive → delete once the archive datasource exists; without it rows are retained, never dropped unarchived. - P4 — governance: new
lifecyclesettings 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_activity14d (rotated),sys_audit_log90d hot → archive (retained forever until anarchivedatasource is registered),sys_metadata_audit365d → archive,sys_job_run/sys_automation_run/sys_http_delivery30d, notification pipeline (sys_notification, delivery, receipt, inbox) 90d,sys_device_codeexpires_at + 1d. Extend windows per environment/tenant via thelifecycle.retention_overridessetting. - P1 — contract: new
- 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
- Updated dependencies [2a71f48]
- Updated dependencies [02f6af4]
- Updated dependencies [c1064f1]
- @objectstack/spec@14.3.0
- @objectstack/core@14.3.0
- @objectstack/formula@14.3.0
- Updated dependencies [ac8f029]
- Updated dependencies [4ab9958]
- @objectstack/spec@14.2.0
- @objectstack/core@14.2.0
- @objectstack/formula@14.2.0
- Updated dependencies [5a8465f]
- Updated dependencies [7f8620b]
- Updated dependencies [82ba3a6]
- @objectstack/spec@14.1.0
- @objectstack/core@14.1.0
- @objectstack/formula@14.1.0
- 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
-
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_role→sys_position,sys_user_role→sys_user_position(fieldrole→position),sys_role_permission_set→sys_position_permission_set(fieldrole_id→position_id);RoleSchema/defineRole→PositionSchema/definePositionwith noparent(positions are flat; hierarchy lives on the business-unit tree).ExecutionContext.roles[]→positions[]; the EvalUser/CEL contractcurrent_user.roles→current_user.positions(formula validators updated); stack propertyroles:→positions:; metadata kindsrole/profile→position(profile kind removed).isProfileremoved fromPermissionSetSchema(ADR-0090 D2);isDefaultnarrows to an install-time suggestion;appDefaultProfileName→appDefaultPermissionSetName(isDefault-only).- OWD enum drops legacy aliases
read/read_write/full; new optionalexternalSharingModel(external dial,privatedefault) lands as P1 spec shape (ADR-0090 D11). - Secure default (D1): a custom object with an owner field and NO
sharingModelnow resolvesprivate(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:
role→position(expanded viasys_user_position∪ the better-auth membership transition source);role_and_subordinatesremoved —unit_and_subordinatesnow expands the business-unit subtree (finishes ADR-0057 D5's re-homing).
- 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
-
0adcc1c: Flow
notifynode: support a click-through target so inbox notifications can be clicked into the related record (#2675).The
notifynode now readssourceObject/sourceId(or the nestedsource: { object, id }form) andactorIdfrom its config and forwards them to the messaging service, which persistssys_notification.source_object/source_id/actor_idand 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.urlis now accepted as an alias foractionUrl(an explicit URL still overrides the synthesized link). The node also publishes aconfigSchemadocumenting all accepted keys for the Studio form.Previously the node consumed only
recipients/title/message/channels, so every notification it emitted hadsource_object/source_id=nulland could not be clicked through to a record.
- Updated dependencies [6cebf22]
- Updated dependencies [21420d9]
- @objectstack/spec@12.6.0
- @objectstack/core@12.6.0
- @objectstack/formula@12.6.0
-
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-updateflow writing back to its own trigger record) looped forever when its boolean re-entry guard never tripped — booleans persist as integer1on SQLite/libsql and CEL1 != trueistrue. During first-boot seed (which awaits automation) this hung the whole kernel build.Three layers:
ExecutionContext.skipTriggers(set by the seed-loader, threaded ontoHookContext.sessionviabuildSession) 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
- Updated dependencies [60dc3ba]
- @objectstack/spec@12.4.0
- @objectstack/core@12.4.0
- @objectstack/formula@12.4.0
- Updated dependencies [e7eceec]
- @objectstack/spec@12.3.0
- @objectstack/core@12.3.0
- @objectstack/formula@12.3.0
- 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
-
8bcd994: Automation run observability follow-ups (#2585): retention for
sys_automation_runrun 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,0disables). - A default-on periodic sweep deletes terminal rows older than 30 days (
runHistoryRetentionDays,0disables;runHistorySweepMstunes 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. NewSuspendedRunStore.loadTerminal(runId)backs this;RunRecordgainsfinishedAtandsteps. - A write-time per-flow cap keeps the newest 100 terminal runs per flow (
-
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".-
registerFlownow honorsstatus: a flow whose deploymentstatusisobsoleteorinvalidis treated as disabled — its trigger is not bound andexecute()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 existingstatusfield, 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 runtimetoggleFlow()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 (persistedstatusis 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
obsoleteflow is neither bound nor enabled andexecute()refuses it, a status flip obsolete→active re-enables + re-binds, and the_statusroute shape. -
- Updated dependencies [93e6d02]
- @objectstack/spec@12.1.0
- @objectstack/core@12.1.0
- @objectstack/formula@12.1.0
-
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
ExecutionLogEntryrecords 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 theSuspendedRunStore(recordTerminal):status(completed/failed),finished_at,duration_ms, and, for a failure, theerrormessage 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 rawrunIdwithstatus: 'paused'), so the suspend save/load/delete/list path is untouched and resume sweeps (list()filtersstatus: '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 thestatusenum gainsrunning/completed/failedalongside the existingpaused.Verified end-to-end on a clean showcase instance: a schedule-triggered flow and seven task-completion flows each left durable
completedrows; a genuinely failing flow (showcase_resilient_sync) left afailedrow carrying itstry_catchfailure reason; a livepausedsuspended run coexisted without collision; and after a full process restart thefailedrow — reason intact — was still queryable via/api/v1/data/sys_automation_run. Newrun-history.test.tscovers completed/failed persistence, read-across-restart, and best-effort isolation.
-
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 manifest —registry.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 themetadata:reloadedhook, which never fires on a cold boot (os devrestarts 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:readyfromprotocol.getMetaItems({ type: 'flow' })— the canonical flattened flow view thatGET /meta/flowserves and that does surface inline app flows — once every plugin has finishedinit()/start()(so the app, hence its flows, is registered).registerFlowis 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.getMetaItemsand asserts it is bound afterbootstrap()alone with nometadata:reloadedfired — 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:
-
The publish path fired no rebind signal.
POST /packages/:id/publish-drafts→protocol.publishPackageDraftspromotes the drafts to active but emitted no event the automation service listens to. The runtime dispatcher now announcesmetadata:reloadedafter a successful publish — the same signal a dev artifact reload fires (MetadataPlugin._reloadAndAnnounce) — so boot-cached consumers re-sync without a restart. -
The runtime re-sync read the wrong source. The automation service's
metadata:reloadedre-sync pulledmetadata.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 readsprotocol.getMetaItems({ type: 'flow' })— the same flattened flow view #2560's cold-boot bind andGET /meta/flowuse — 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-draftswithout 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 assertmetadata:reloadedbinds it — and that the re-sync reads the protocol, notmetadata.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
-
6a9397e: Retire the deprecated
compactLayoutalias forhighlightFields(framework#2536, closes the ADR-0085 deprecation window).ObjectSchemano longer declarescompactLayout:create()rejects it like any unknown key; lenientparse()strips it (no silent aliasing).- The parse-time alias AND the
highlightFields → compactLayoutback-fill transition mirror are removed fromnormalizeSemanticRoleAliases. 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: undefinedin 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
compactLayoutwill now failcreate()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
- Updated dependencies [d3595d9]
- @objectstack/spec@11.9.0
- @objectstack/core@11.9.0
- @objectstack/formula@11.9.0
- @objectstack/spec@11.8.0
- @objectstack/core@11.8.0
- @objectstack/formula@11.8.0
- Updated dependencies [5178906]
- @objectstack/spec@11.7.0
- @objectstack/core@11.7.0
- @objectstack/formula@11.7.0
- @objectstack/spec@11.6.0
- @objectstack/core@11.6.0
- @objectstack/formula@11.6.0
- Updated dependencies [6ee4f04]
- Updated dependencies [c1e3a65]
- @objectstack/spec@11.5.0
- @objectstack/core@11.5.0
- @objectstack/formula@11.5.0
- Updated dependencies [5821c51]
- Updated dependencies [a0fce3f]
- @objectstack/spec@11.4.0
- @objectstack/core@11.4.0
- @objectstack/formula@11.4.0
- Updated dependencies [58e8e31]
- Updated dependencies [b4a5df0]
- @objectstack/spec@11.3.0
- @objectstack/core@11.3.0
- @objectstack/formula@11.3.0
- Updated dependencies [d0f4b13]
- Updated dependencies [302bdab]
- @objectstack/spec@11.2.0
- @objectstack/core@11.2.0
- @objectstack/formula@11.2.0
- 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
-
82ff91c: Remove the deprecated
http_request/http_call/webhookflow-node aliases — authorhttp(ADR-0018 M3).ADR-0018 M3 collapsed the divergent outbound-callout verbs onto the canonical
httpnode and kept the old names as deprecated aliases for back-compat. This removes those aliases (the 11.0 cleanup):http_requestis dropped fromFlowNodeAction(and thereforeFLOW_BUILTIN_NODE_TYPES); authoring it now fails fast at parse instead of resolving tohttp.AutomationEngineno longer registers thehttp_request/http_call/webhooknode aliases; onlyhttpis 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 whenconfig.durable, inline fetch otherwise). The triggereventType: 'webhook'and thewebhookresume event are unaffected — only the HTTP node aliases are removed. First-party examples (showcase, app-crm) are migrated.
-
6c4fbd9: fix(security): enforce flow
runAsexecution identity (#1888)The
service-automationengine now honorsflow.runAsinstead 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 ofrunAs. ArunAs:'user'flow did not de-elevate (a privilege-boundary surprise), andrunAs:'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 resolvedroles/tenantIdinto theAutomationContext(new optional fields), not justuserId. The newresolveRunDataContexthelper is the single place that maps a run's effectiverunAsto the ObjectQL context, shared by every data node.The
[EXPERIMENTAL — not enforced]marker is removed fromFlowSchema.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. DeclarerunAs:'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; underuserthey stay unscoped (there is no identity to scope to) — declaresystemto 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.runAsnow enforced (#1888), a schedule-triggered flow with the defaultrunAs:'user'has no trigger user.resolveRunDataContextreturnsundefinedfor that case, so the CRUD nodes pass no ObjectQLoptions.contextand 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 leftrunAsat 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 ruleflow-schedule-runas-unscopedflags a schedule-triggered flow whose effectiverunAsisuser(explicit or unset) and which performs a data operation — pointing the author atrunAs:'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 helpersrunIsUnscopedUserMode,flowTouchesData, andDATA_NODE_TYPESare exported alongsideresolveRunDataContext. - Spec describe (
@objectstack/spec):FlowSchema.runAsnow states that a scheduled run has no user, so underuserit runs unscoped — declaresystem.
The first-party example apps that tripped the new lint are fixed to declare
runAs:'system'explicitly (stale_opportunity_sweep, the app-todotask_reminder/overdue_escalationsweeps) — 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 realScheduleTriggerto 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: arunAs:'user'run reads + writes an owner-scoped note a member cannot — audibly — whilerunAs:'system'is the explicit, warning-free equivalent.Refs #1888, ADR-0049.
- Author-time lint (
-
4b5ec6e: fix(automation): re-bind scheduled-flow jobs on
os devhot-reloadEditing a schedule-triggered flow under
objectstack devsilently kept firing the OLD definition until a full server restart. The dev watcher recompilesdist/objectstack.jsonand 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 (oldrunAs, schedule, or logic) on its timer, with no signal that the edit had no effect.Fix: MetadataPlugin now fires a generic
metadata:reloadedhook 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:registerFlowre-binds the trigger, andScheduleTrigger.startcancels + 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
assignmentswrapper shape on assignment nodesThe built-in
assignmentnode executor set each TOP-LEVELconfigkey as a flow variable. But the surfaces that author these nodes all emit anassignmentswrapper 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
assignmentsto 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 (
assignmentsmap,assignmentsarray of{ variable | name | key, value }, and the legacy flat{ <var>: <value> }) and interpolates{var}templates in the values, matching the CRUD / screen nodes. Addslogic-nodes.test.tscovering each shape as a regression guard. - Studio's visual Assignment editor →
-
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
- @objectstack/spec@10.3.0
- @objectstack/core@10.3.0
- @objectstack/formula@10.3.0
- Updated dependencies [b496498]
- @objectstack/spec@10.2.0
- @objectstack/core@10.2.0
- @objectstack/formula@10.2.0
- Updated dependencies [49da36e]
- Updated dependencies [ac79f16]
- @objectstack/spec@10.1.0
- @objectstack/core@10.1.0
- @objectstack/formula@10.1.0
- 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
- 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
- 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
- @objectstack/spec@9.9.1
- @objectstack/core@9.9.1
- @objectstack/formula@9.9.1
-
134043a: feat(automation): declarative screen-flow completion/error messages + action
errorMessageA screen flow can now declare
successMessage/errorMessage(FlowSchema). The engine surfaces them on the terminalAutomationResult(successMessageon success,errorMessageon failure), so the UI flow-runner shows a meaningful toast instead of a generic "Done" / the raw error — no manual "success screen" node needed. The CRM convert-lead wizard sets a friendly completion message.Also exposes
errorMessageon the UI Action schema. The runtime (ActionRunner) already honoured it; it just wasn't declarable in the spec — closing a spec↔runtime gap so authors can set a friendly failure toast. -
6bec07e: feat(automation): object-form screen-flow steps
A
screennode that declaresconfig.objectNamenow renders the named object's FULL create/edit form (including inline master-detail child grids) instead of a flat field list. The node emits anobject-formScreenSpec(kind/objectName/mode/recordId/defaults/idVariable); the client renders the real ObjectForm, persists the record (and its children, atomically), and resumes the run with the saved id bound toidVariableso a later step can reference it — e.g. a lead-conversion wizard: a full Customer step, then a full Opportunity-with-line-items step.- spec:
ScreenSpecgainskind/objectName/mode/recordId/defaults/idVariable. - service-automation: the
screenexecutor emits object-form specs and now interpolatestitle/description/fielddefaultValue/object-formdefaultsagainst live flow variables (the engine does not pre-interpolate node config).
- spec:
- 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
- Updated dependencies [c17d2c8]
- Updated dependencies [97c55b3]
- Updated dependencies [1b1f490]
- @objectstack/formula@9.8.0
- @objectstack/spec@9.8.0
- @objectstack/core@9.8.0
- Updated dependencies [82c7438]
- Updated dependencies [417b6ac]
- Updated dependencies [ff0a87a]
- @objectstack/formula@9.7.0
- @objectstack/spec@9.7.0
- @objectstack/core@9.7.0
-
6c82aa0: fix(automation):
create_recordoutputVariable exposes the created record so{var.id}resolves (#1873)A
create_recordnode stored only the created record's id string in itsoutputVariable, 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_recordalready stores the record object (that's why{rec.field}works there);create_recordnow matches.Behavior change:
outputVariableholds the created record (an object withid+ 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-repoapp-todocreate-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
scriptnode that pointed at an unregistered callable (or declared noactionType/functionat all) built fine and silently did nothing at runtime. Two changes close that gap:- Loud runtime resolution. The built-in
scriptexecutor now resolves its target in order — built-in side-effect (email/slack) → a registered function (config.function, or a bareconfig.actionTypethat 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'sresolveFunction(populated frombundle.functions/defineStack({ functions })), so an authored package can register a function and call it from ascriptnode:{ type: 'script', config: { function: 'my_fn', inputs: { … } } }. - Build-time structural check.
objectstack buildnow flags ascriptnode that declares neitheractionTypenorfunction(theactionType: undefinedrepro). Function existence is verified at runtime — functions are code, not serialized into the artifact.
- Loud runtime resolution. The built-in
-
1402be0: feat(automation): script-node
outputVariable+ interpolated inputs — the pure-function pattern (#1870)A flow
function(script node) is a PURE compute step: it receivesctx.inputand 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.outputVariableexposes the function's return value as a flow variable, so a later declarative node persists it (update_record fields: { x: '{ai.x}' }).config.inputsare 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.
-
b0df09c: fix(automation): record-change flows see multi-lookup fields + support array-index interpolation (#1872)
A
multiple: truelookup 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 != nullwas false and{rec.target_channels.0}resolved empty. Two fixes:- trigger-record-change:
buildContextnow reads the lifecycle hook'sinput.data(the actual key objectql uses for insert/update; it had been reading a non-existentinput.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}).
- trigger-record-change:
-
ab942f2: feat(automation): accept
functionNamealias +invoke_functionmarker on script nodes (#1870 DX)AI-authored templates commonly emit
config: { actionType: 'invoke_function', functionName: 'my_fn' }, but the runtime only readconfig.function. Now:config.functionNameis accepted as an alias forconfig.function(runtime + build).actionType: 'invoke_function'is treated as a MARKER ("call the named function") — the name comes fromfunction/functionName, not from actionType itself; it no longer tries to resolve a function literally namedinvoke_function.objectstack builderrors onactionType: 'invoke_function'with nofunction/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
- Updated dependencies [ee72aae]
- @objectstack/spec@9.5.1
- @objectstack/core@9.5.1
- @objectstack/formula@9.5.1
-
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'sretentionDaysdefaults toDEFAULT_NOTIFICATION_RETENTION_DAYS(90) instead of0; the already-built/tested sweeper now prunessys_notification(+ delivery / inbox / receipt) older than 90 days by default. Behaviour change: notification history auto-prunes at 90d — setretentionDays: 0to 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 toDEFAULT_MAX_EXECUTION_LOG_SIZE(1000, unchanged). Durablesys_automation_run-style persistence remains a post-GA HA item.
- service-messaging — notification-pipeline retention is now default-on.
- 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
- 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
- 290f631: ADR-0044 flow-level send-back-for-revision (#1744). The approval node gains a third flow movement beyond approve/reject:
sendBack()finalizes the pending request asreturned(newApprovalStatus), resumes the run down itsreviseedge to a wait point where the record lock releases, and the submitter'sresubmit()re-enters the approval node over a declared back-edge, opening the next round's request (fresh approver slate, re-locked,roundstamped via the config snapshot). Engine:FlowEdgeSchema.typegains'back'— cycle validation now requires the graph minus back-edges to be a DAG (unmarked cycles still rejected), node re-entry overwrites outputs/appends steps, a 100-re-entry runaway guard backstops misauthored loops, andcancelRun(runId, reason)lands as the first run-cancel primitive (recall crossing a revise window cancels the parked run).maxRevisions(default 3) on the approval node config auto-rejects send-backs past the budget. REST:POST /approvals/requests/:id/reviseand/resubmit. Audit kindsrevise/resubmitjoinApprovalActionKindand thesys_approval_actionenum. - ad4e97f: ADR-0041 Tier 1 complete:
@objectstack/trigger-api— inbound webhook/HTTP flow trigger. The engine now derives anapitrigger binding fortype: 'api'flows (activating the long-reserved enum value); the trigger mountsPOST /api/v1/automation/hooks/:flowName/:hookIdwith 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), andx-idempotency-keypasses through to the queue's dedup window. The CLI's serve preset auto-loads the trigger alongside record-change and schedule.
- 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
- Updated dependencies [2f57b75]
- Updated dependencies [2f57b75]
- @objectstack/spec@9.2.0
- @objectstack/core@9.2.0
- @objectstack/formula@9.2.0
- Updated dependencies [b9062c9]
- @objectstack/spec@9.1.0
- @objectstack/core@9.1.0
- @objectstack/formula@9.1.0
- Updated dependencies [1817845]
- @objectstack/spec@9.0.1
- @objectstack/core@9.0.1
- @objectstack/formula@9.0.1
- 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
- @objectstack/spec@8.0.1
- @objectstack/core@8.0.1
- @objectstack/formula@8.0.1
-
3306d2f: feat(automation): surface structured-region body steps in run observability (#1505)
loop/parallel/try_catchpreviously ran their body, branch, and handler regions against a region-local step log that was discarded — run logs (listRuns/getRun) showed the container as a single opaque step, hiding the per-iteration / per-branch steps that actually executed.AutomationEngine.runRegion()now returns its body steps, and the container node folds them into the parent run log via a newNodeExecutionResult.childStepsfield. Each surfaced step is tagged with its immediate container via three new optional fields onExecutionStepLogSchema(and the engine'sStepLogEntry):parentNodeId— the enclosingloop/parallel/try_catchnodeiteration— zero-based loop iteration or parallel branch indexregionKind—loop-body|parallel-branch|try|catch
Tagging fills only fields left undefined, so nested regions keep each step's innermost container. A failed try-region attempt's partial steps are still not surfaced (preserving
try_catchretry semantics). Fully additive — existing run logs and consumers are unaffected. -
bc44195: chore(automation): retire the
workflow_ruleauthoring paradigm (ADR-0018 M5 dropped)ADR-0019 already removed the Workflow-Rule → Flow compiler (Workflow Rules were removed in #1398 and
workflowwas reclaimed for state machines), but theworkflow_ruleparadigm tag survived inActionParadigmSchemaand on every built-in node descriptor. There is no declarative Workflow-Rule authoring view to feed, so the tag is now retired:ActionParadigmSchemakeeps['flow', 'approval'], and thehttp/notify/connector_actiondescriptors (plus the deprecated-alias fallback) advertise['flow', 'approval']. Approval execution convergence is delivered by the ADR-0019 approval Flow node, not a compiler. ADR-0018's status and migration table are updated to mark M3 shipped, M4 framework-complete, and M5 dropped. -
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
- @objectstack/spec@7.9.0
- @objectstack/core@7.9.0
- @objectstack/formula@7.9.0
- 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
- 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
-
955d4c8: ADR-0018 M3: unified
http/notifyexecutors backed by a generic HTTP outbox.Promotes a reliable outbound-HTTP delivery outbox into
service-messaging(the raw-callout counterpart to the notification outbox) and routes the Flowhttpnode through it — closing the "http_requestis a barefetch()with no retry" gap. The five divergent outbound verbs collapse onto canonicalhttp/notify.@objectstack/service-messaging(additive):IHttpOutbox/HttpDeliverygeneric raw-callout shape (source/refId/dedupKey/label/signingSecret),SqlHttpOutboxover a newsys_http_deliveryobject,MemoryHttpOutbox,HttpDispatcher(per-partition cluster lock, claim/ack/retry/dead-letter), and a sharedsendOnce+ 7-step jittered retry schedule.MessagingServicegainssetHttpOutbox()/isHttpDeliveryReady()/enqueueHttp(); the plugin wires the outbox + dispatcher atkernel:ready.
@objectstack/service-automation:- Canonical
httpexecutor —durable: trueenqueues onto the messaging HTTP outbox (retry/dead-letter); otherwise an inlinefetch()preservinghttp_request's request/response semantics. engine.registerNodeAlias()— registers a delegating executor + adeprecated/aliasOfdescriptor.http_request/http_call/webhookare now deprecated aliases ofhttp; existing flows keep running.notifydescriptor markedneedsOutbox(its delivery is outbox-backed).
@objectstack/spec:flow.zodaddshttpto the builtin node-type seed set.plugin-webhookscut-over to the shared outbox is a deliberate follow-up. -
c4a4cbd: ADR-0032 (phase 1): validate-by-default expression layer — no silent failure.
Kills the #1491 class where a malformed predicate (e.g. the
{record.x}template-brace-in-CEL mistake) silently evaluated tofalseand made a flow "fire" with no effect:- service-automation: flow
evaluateConditionno longer swallows CEL failures tofalse— it throws an attributed, corrective error; andregisterFlownow parse-validates every predicate (start/decision/edge condition) at registration, failing loudly with the offending location + source + the fix. - formula: new shared validator —
validateExpression(role, src, schema?),introspectScope,CEL_STDLIB_FUNCTIONS— with schema-aware field-existence- did-you-mean. The
{{ }}template engine gains a formatter whitelist (currency/number/percent/date/datetime/truncate/upper/lower/default/…) with defined value→string semantics; arbitrary logic in holes is rejected. Plain{{ path }}stays back-compatible.
- did-you-mean. The
- cli:
objectstack compilevalidates every flow / validation-rule / field-formula predicate against the resolved object schema and fails the build with located, corrective messages. - service-ai: new agent-callable
validate_expressiontool so authoring agents self-correct before committing. - spec: fix the
FlowSchemaJSDoc example that taught the badcondition: "{amount} < 500"single-brace form.
- service-automation: flow
-
cf03ef2: Persist suspended flow runs so a durable pause survives a process restart (#1518).
service-automationkept suspended runs in an in-memoryMaponly, so a flow paused at anapproval/wait/screennode 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 butresume(runId)had nothing to continue.The engine now backs that map with a pluggable
SuspendedRunStore(ADR-0019):SuspendedRunStoreinterface + two implementations —InMemorySuspendedRunStore(the default; JSON round-trips so it faithfully mirrors a DB boundary) andObjectStoreSuspendedRunStore, which persists to a newsys_automation_runsystem object via the ObjectQL engine.AutomationServicePluginregisters the object and auto-enables the DB-backed store when an ObjectQL engine is present (opt out withsuspendedRunStore: '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, plusAutomationEngine.setSuspendedRunStore()andlistSuspendedRunsDurable(). 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.tsdefining theloopcontainer (config.body),parallelblock (config.branches[], implicit join), andtry/catch/retry(config.try/config.catch/config.retry) configs, plus region well-formedness analysis (analyzeRegion,findRegionEntry) andvalidateControlFlow(single-entry/single-exit, acyclic; bounded loop). - engine:
registerFlow()now rejects malformed control-flow regions before a flow can run; newAutomationEngine.runRegion()executes a body region in the enclosing variable scope without touching the shared DAG traversal. - loop executor: replaces the no-op
loopstub with a real iteration container — binds the iterator/index variables and runs the body once per item under a hard max-iteration guard. Legacy flat-graph loops (noconfig.body) keep working — the construct is additive.
Parallel-block and try/catch engine execution and BPMN interop mapping remain follow-ups (issue #1479, tasks 3–5).
- spec: new
-
f06a6a5: feat(automation): structured parallel block (ADR-0031, task 3)
Implement engine execution for the
parallelblock — a structured construct with an implicit join (ADR-0031 §Decision 2). Theparallelnode declares N branch regions inconfig.branches[]; the executor runs them concurrently in the enclosing variable scope (viaAutomationEngine.runRegion) and continues once when all branches complete — no author-visible split/join gateway.- New
builtin/parallel-node.tsexecutor (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()byvalidateControlFlow(shipped with the loop container).
Showcase
FanOutNotifyFlowdemonstrates the parallel block. Try/catch execution and BPMN interop mapping remain follow-ups (#1479 tasks 4–5). - New
-
4ee139d: feat(automation): structured try/catch/retry block (ADR-0031, task 4)
Implement engine execution for the
try_catchconstruct — structured error handling (ADR-0031 §Decision 3). The node runs a protectedtryregion; on failure it retries with exponential backoff (config.retry), and if it still fails the optionalcatchregion runs with the caught error bound toconfig.errorVariable(default$error). Both regions execute in the enclosing variable scope viaAutomationEngine.runRegion.- New
builtin/try-catch-node.tsexecutor (registered as a built-in). trysuccess (incl. a successful retry) → node succeeds;catchhandling a failure → node succeeds; nocatch/ failingcatch→ node fails to the flow's fault edge / error handling.- Well-formedness (single-entry/single-exit
try/catchregions) is already enforced atregisterFlow()byvalidateControlFlow(shipped with the loop container).
Showcase
ResilientSyncFlowdemonstrates the construct. This completes the native control-flow execution trio (loop / parallel / try-catch); BPMN interop mapping remains a follow-up (#1479 task 5). - New
- 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
-
1560880: Implement the
subflownode executor — invoke another flow as a reusable step.The designer offered a
subflownode but the engine had no executor, so a flow using it couldn't run.subflownow:- resolves
config.input(a{token}mapping) against the parent's variables, - runs
config.flowNameviaengine.execute(...), and - writes the child's output back — under
${nodeId}.output, and underconfig.outputVariableas 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
AutomationServicePluginnow ships 14 executors includingsubflow.Tests:
subflow-node.test.ts— invoke + input-mapping + output capture, missingflowName, child-not-found, child-suspended, recursion guard. service-automation 118 passing. Worked examples added to the showcase: a reusableshowcase_notify_ownersubflow (template: true) invoked byshowcase_task_done_notify_owner. - resolves
-
a2263e6: Implement the
waitnode executor — durable timer / signal pause.The flow designer offered a
waitnode but the engine had no executor for it, so a flow using it couldn't run.waitnow suspends the run on entry (ADR-0019 durable pause, the same suspend/resume machinery asscreen/approval) and resumes by one of two paths, perwaitEventConfig.eventType:- timer — schedules a one-shot job (
IJobService,{ type: 'once', at }) that callsengine.resume(runId)when the ISO-8601timerDurationelapses. With no job service the run still suspends and is resumable via an externalresume(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
$runIdvariable (same mechanism the approval node uses). Adds aparseIsoDurationhelper (PT1H,P3D,PT90M,P1DT12H, bare ms). Registered as a built-in node, so a bareAutomationServicePluginnow ships 13 executors includingwait.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 workedshowcase_task_follow_upflow (wait → notify) demonstrates it end-to-end. - timer — schedules a one-shot job (
- @objectstack/spec@7.5.0
- @objectstack/core@7.5.0
- @objectstack/formula@7.5.0
- @objectstack/spec@7.4.1
- @objectstack/core@7.4.1
- @objectstack/formula@7.4.1
-
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) — breakingMessagingService.emittakes the newEmitInputcontract (topic/audience/payload/severity/dedupKey/source/actorId/organizationId/channels) instead of the flatNotificationshape. It writes the L2sys_notificationevent (idempotent ondedupKey), resolves the audience, then fans out; it returns{ notificationId, deduped, deliveries, delivered, failed }.- New
sys_notification_receiptobject — the read-state spine (delivered|read|clicked|dismissed), keyed(notification_id, user_id, channel). The inbox channel writes adeliveredreceipt on materialization. sys_inbox_message: addsnotification_id/delivery_id, dropsread(read-state moved to the receipt), adds the userminelist view.
Event re-model (
@objectstack/platform-objects) — breakingsys_notificationis re-modeled from a per-user inbox into the L2 event (topic,payload,severity,dedup_key,source_*,actor_id). Removesrecipient_id/is_read/read_at/type/title/body/url/actor_nameand the inbox actions/views. App-nav: the account inbox points atsys_inbox_message; Setup shows the notification event log.
Producers routed through
emit()@objectstack/service-automation: thenotifynode maps its config toEmitInput.@objectstack/plugin-audit: collaboration@mention→collab.mentionand assignment →collab.assignment(both with adedupKey); no more directsys_notificationwrites. Collaboration notifications now requireMessagingServicePlugin(they degrade to a warn otherwise).
Migration (
@objectstack/metadata)- Idempotent
migrateSysNotificationToEventsplits legacysys_notificationinbox rows intosys_inbox_message+ receipts and rewrites the event row.
Startup (
@objectstack/cli,@objectstack/runtime)messagingis now a foundational capability. Onobjectstack serveit is added toALWAYS_ON_CAPABILITIES(every non-minimalpreset starts it); on cloud per-project kernels the capability loader expandsrequiresto addmessagingwheneverauditis present. This keeps collaboration@mention/ assignment notifications (which now flow through the pipeline) working out of the box on both paths.--preset minimalopts 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
FlowTriggerextension point so flows whosestartnode declares a record-change trigger (config: { objectName, triggerType: 'record-after-update', condition }) actually fire on the matching mutation. Previously the slot was dead — nothing calledtrigger.start— so such flows could only run via a manualengine.execute().Engine baseline (
@objectstack/service-automation)- Redefines
FlowTriggeraround a parsedFlowTriggerBinding(flowName, object, event, condition, schedule, raw config). The engine parses the start node and hands the trigger a normalized binding, keeping trigger plugins decoupled from flow-definition internals (mirrorsconnector_action↔connector-rest). - Ordering-independent, bidirectional wiring:
registerFlow/toggleFlowactivate bindings;registerTriggerretro-binds already-registered flows (a trigger plugin wires up onkernel:ready, after flows are pulled in);unregisterFlow/unregisterTrigger/disable tear them down. - Centralized start-condition gate in
execute(): the start node'scondition(e.g.status == 'done' && previous.status != 'done') is evaluated once for every trigger type and manual runs; false ⇒{ skipped: true }. - Seeds
record, flattened record fields, andpreviousinto flow variables. - New
getActiveTriggerBindings()getter + exportsFlowTriggerBinding.
Spec (
@objectstack/spec)- Adds
previous?toAutomationContext— the pre-update "old" row, so flows can gate on transitions.
New package (
@objectstack/plugin-trigger-record-change)- The concrete trigger: subscribes to ObjectQL lifecycle hooks
(
record-after-update→afterUpdate, etc.), builds anAutomationContextfrom the new/old record, and runs the flow. Error-isolated (a flow failure never breaks the CRUD write); graceful degrade when the automation service or ObjectQL engine is absent (mirrorsplugin-audit).
The
scheduletrigger (ticker/cron +sys_joblifecycle) is a follow-up. - Redefines
-
ff3d006: Screen-flow runtime — interactive
screennodes (suspend → render → resume).A
screennode that declares input fields now suspends the run on entry (reusing the ADR-0019 durable pause), surfaces aScreenSpecdescribing the form, and resumes with the collected values applied as bare flow variables so downstream nodes read them via{var}. (waitForInput: falseforces the old server pass-through.)- spec:
AutomationResult.screen?: ScreenSpec,ResumeSignal.variables?(bare vars),IAutomationService.getSuspendedScreen?(runId). - service-automation: the
screenexecutor builds theScreenSpecand suspends when fields are present; the suspend/resume plumbing threads the screen throughFlowSuspendSignal→SuspendedRun→ the paused result;resume()setssignal.variablesas bare flow variables;getSuspendedScreen. - runtime:
POST /api/v1/automation/:name/runs/:runId/resume(body{ inputs }) andGET …/runs/:runId/screen, wired through both the dispatcher route table andhandleAutomation.
Verified end-to-end headlessly: the showcase Reassign Wizard launches → pauses at the "New Assignee" screen → resumes with the input → the task is reassigned. The objectui
FlowRunnerUI that renders these screens ships separately. - spec:
-
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 andbudget > 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__requirestub, so every CEL evaluation threw and the swallowingcatchreturnedfalse. 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.
HookContextdocuments aprevioussnapshot for update/delete, butengine.updatenever populated it (the row it fetched for validation was a local var). Record-change conditions likestatus == "done" && previous.status != "done"therefore had nopreviousto read. The engine now attaches the pre-update record tohookContext.previousfor single-id updates whenever a validation rule needs it or anafterUpdatehook 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
- Updated dependencies [5e7c554]
- @objectstack/spec@7.3.0
- @objectstack/core@7.3.0
- @objectstack/formula@7.3.0
- @objectstack/spec@7.2.1
- @objectstack/core@7.2.1
- @objectstack/formula@7.2.1
- @objectstack/spec@7.2.0
- @objectstack/core@7.2.0
- @objectstack/formula@7.2.0
- Updated dependencies [47a92f4]
- @objectstack/spec@7.1.0
- @objectstack/core@7.1.0
- @objectstack/formula@7.1.0
- Updated dependencies [74470ad]
- Updated dependencies [d29617e]
- Updated dependencies [dc72172]
- @objectstack/spec@7.0.0
- @objectstack/core@7.0.0
- @objectstack/formula@7.0.0
- @objectstack/spec@6.9.0
- @objectstack/core@6.9.0
- @objectstack/formula@6.9.0
- @objectstack/spec@6.8.1
- @objectstack/core@6.8.1
- @objectstack/formula@6.8.1
- Updated dependencies [6e88f77]
- Updated dependencies [c8b9f57]
- @objectstack/spec@6.8.0
- @objectstack/core@6.8.0
- @objectstack/formula@6.8.0
- @objectstack/spec@6.7.1
- @objectstack/core@6.7.1
- @objectstack/formula@6.7.1
- Updated dependencies [430067b]
- Updated dependencies [4f9e9d4]
- @objectstack/spec@6.7.0
- @objectstack/core@6.7.0
- @objectstack/formula@6.7.0
- Updated dependencies [a49cfc2]
- @objectstack/spec@6.6.0
- @objectstack/core@6.6.0
- @objectstack/formula@6.6.0
- @objectstack/spec@6.5.1
- @objectstack/core@6.5.1
- @objectstack/formula@6.5.1
- @objectstack/spec@6.5.0
- @objectstack/core@6.5.0
- @objectstack/formula@6.5.0
- Updated dependencies [f8651cc]
- Updated dependencies [f8651cc]
- Updated dependencies [0bf6f9a]
- @objectstack/spec@6.4.0
- @objectstack/core@6.4.0
- @objectstack/formula@6.4.0
- @objectstack/spec@6.3.0
- @objectstack/core@6.3.0
- @objectstack/formula@6.3.0
- Updated dependencies [b4c74a9]
- @objectstack/spec@6.2.0
- @objectstack/core@6.2.0
- @objectstack/formula@6.2.0
- @objectstack/spec@6.1.1
- @objectstack/core@6.1.1
- @objectstack/formula@6.1.1
- Updated dependencies [93c0589]
- @objectstack/spec@6.1.0
- @objectstack/core@6.1.0
- @objectstack/formula@6.1.0
- Updated dependencies [629a716]
- Updated dependencies [dbc4f7d]
- Updated dependencies [944f187]
- @objectstack/spec@6.0.0
- @objectstack/core@6.0.0
- @objectstack/formula@6.0.0
- Updated dependencies [bab2b20]
- Updated dependencies [fa011d8]
- Updated dependencies [b806f58]
- @objectstack/spec@5.2.0
- @objectstack/core@5.2.0
- @objectstack/formula@5.2.0
- Updated dependencies [75f4ee6]
- Updated dependencies [823d559]
- @objectstack/spec@5.1.0
- @objectstack/core@5.1.0
- @objectstack/formula@5.1.0
- Updated dependencies [2f9073a]
- @objectstack/spec@5.0.0
- @objectstack/core@5.0.0
- @objectstack/formula@5.0.0
- Updated dependencies [2869891]
- @objectstack/spec@4.2.0
- @objectstack/core@4.2.0
- @objectstack/formula@4.2.0
- @objectstack/spec@4.1.1
- @objectstack/core@4.1.1
- @objectstack/formula@4.1.1
- Updated dependencies [2108c30]
- Updated dependencies [23db640]
- @objectstack/spec@4.1.0
- @objectstack/core@4.1.0
- @objectstack/formula@4.1.0
- 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
- Updated dependencies [326b66b]
- @objectstack/spec@4.0.4
- @objectstack/core@4.0.4
- @objectstack/spec@4.0.3
- @objectstack/core@4.0.3
- Updated dependencies [5f659e9]
- @objectstack/spec@4.0.2
- @objectstack/core@4.0.2
- Updated dependencies [f08ffc3]
- Updated dependencies [e0b0a78]
- @objectstack/spec@4.0.0
- @objectstack/core@4.0.0
- @objectstack/spec@3.3.1
- @objectstack/core@3.3.1
- @objectstack/spec@3.3.0
- @objectstack/core@3.3.0
- @objectstack/spec@3.2.9
- @objectstack/core@3.2.9
- @objectstack/spec@3.2.8
- @objectstack/core@3.2.8
- @objectstack/spec@3.2.7
- @objectstack/core@3.2.7
- @objectstack/spec@3.2.6
- @objectstack/core@3.2.6
- @objectstack/spec@3.2.5
- @objectstack/core@3.2.5
- @objectstack/spec@3.2.4
- @objectstack/core@3.2.4
- @objectstack/spec@3.2.3
- @objectstack/core@3.2.3
- Updated dependencies [46defbb]
- @objectstack/spec@3.2.2
- @objectstack/core@3.2.2
- Updated dependencies [850b546]
- @objectstack/spec@3.2.1
- @objectstack/core@3.2.1
- Updated dependencies [5901c29]
- @objectstack/spec@3.2.0
- @objectstack/core@3.2.0
- Updated dependencies [953d667]
- @objectstack/spec@3.1.1
- @objectstack/core@3.1.1
- Updated dependencies [0088830]
- @objectstack/spec@3.1.0
- @objectstack/core@3.1.0
- Updated dependencies [92d9d99]
- @objectstack/spec@3.0.11
- @objectstack/core@3.0.11
- Updated dependencies [d1e5d31]
- @objectstack/spec@3.0.10
- @objectstack/core@3.0.10
- Updated dependencies [15e0df6]
- @objectstack/spec@3.0.9
- @objectstack/core@3.0.9
- Updated dependencies [5a968a2]
- @objectstack/spec@3.0.8
- @objectstack/core@3.0.8
{ "output": { "$mapItemDone": true, "$mapItemOutput": { "result": "FORGED" } } }