Cyoda-Go v0.8.3
Scheduled state transitions, Cloud-aligned search semantics, and honest attribution for follow-on actions.
Released 27 July 2026 · 19 issues delivered
This release adds time to the workflow engine: a transition can now declare a delay and fire on its own, with the firing time computed per entity by a compute node if a static delay is not enough. Search gains a single type-directed evaluation kernel shared by every in-process code path, replacing two implementations that had drifted, and direct search becomes bounded-or-fail on every backend rather than silently truncating. Alongside that, a cascaded or scheduled action is now recorded against the user who caused it instead of the service account that happened to run it.
✨ Highlights
Section titled “✨ Highlights”- Scheduled state transitions: A transition can carry
schedule: {delayMs, timeoutMs}and fire automatically, durably armed and cancelled with the entity write, coordinated across the cluster. - Per-entity firing times:
schedule.functioncalls out to a compute node to compute a transition’s fire and expiry times per entity. - Cloud-aligned search: Predicate evaluation is type-directed and same-type only, on one shared kernel, matching Cyoda Cloud’s model.
- Bounded-or-fail direct search: A matched set larger than
limitnow fails with400 SEARCH_RESULT_LIMITinstead of returning a truncated page that looks complete. - Attribution for follow-on actions: Cascaded writes and scheduled fires are attributed to the originating principal while still executing with system authority.
- Criterion stoppage reason: A criteria compute node can explain why it blocked a passage, surfaced on the rejection response and the audit trail.
- Tx-aware search pushdown: In-transaction search no longer falls back to a full-model scan.
🔍 Details
Section titled “🔍 Details”⏰ Scheduled state transitions
Section titled “⏰ Scheduled state transitions”A transition carrying schedule: {delayMs, timeoutMs} fires delayMs after the
entity enters its source state. Timers are durable, stored per backend
(memory/sqlite/postgres) and armed or cancelled atomically with the entity
write. A cluster coordinator (lowest-live-node-ID, pluggable) scans due tasks and
distributes them round-robin to a fire-and-forget peer RPC; the firing node
re-reads the task and entity as a guard, so a stale or superseded task is a
silent no-op.
The transition’s criterion is evaluated once at fire time — false declines
the transition and the entity stays put, with no retry. A lateness grace band
separates expiry (timeoutMs exceeded, dropped unfired) from firing, so clock
skew between nodes cannot produce a contradictory expire-and-fire. Any entity
write leaving the entity in the same state — including a routine data update or
a self-loop — resets the timer. Firing a scheduled transition explicitly by name
still returns 400 TRANSITION_NOT_FOUND.
New audit events: SCHEDULED_TRANSITION_ARM / FIRE / EXPIRE / CANCEL. New
env vars: CYODA_SCHEDULER_ENABLED, CYODA_SCHEDULER_SCAN_INTERVAL,
CYODA_SCHEDULER_BATCH_SIZE, CYODA_SCHEDULER_DISTRIBUTION,
CYODA_SCHEDULER_COORDINATOR, CYODA_SCHEDULER_REDISPATCH_BACKOFF,
CYODA_SCHEDULER_EXPIRY_GRACE — documented under the new
cyoda help config scheduler topic.
🧮 Per-entity firing time via a Function callout
Section titled “🧮 Per-entity firing time via a Function callout”A static delayMs fires every entity at the same offset. For a per-entity
deadline — an expiresAt on the entity, a per-customer SLA, a user-chosen
reminder — a schedule may instead carry function, mutually exclusive with
delayMs, naming a compute node routed by calculationNodesTags exactly like an
externalized processor or criterion.
This introduces a generic Function callout: a third shape alongside Processor
(mutates the entity) and Criterion (returns a bool), returning a declared typed
value without mutating anything. For scheduling it returns a composite Schedule
result — {fireAt | fireAfterMs, expireAt? | expireAfterMs?}, absolute epoch-ms
or relative — resolved at arm time into the same scheduledTime/timeoutMs a
static schedule would populate. It is invoked on every re-arm, not once on
entry.
The path fails closed. The callout runs synchronously inside the entity-write
transaction, so a callout failure fails that write. A compute node that is
unreachable, disconnected, or timing out fails the write with a retryable 503
— no state change commits against an unschedulable transition. A malformed or
wrong-kind result fails it with the new 500 SCHEDULE_FUNCTION_INVALID_RESULT.
The one case where the write still succeeds is born expired: a resolved
expiry at or before the resolved fire time leaves the transition unarmed,
cancels any prior scheduling for it, and records a
SCHEDULED_TRANSITION_EXPIRE audit event. There is no silent skip.
Relatedly, the compute-infrastructure codes NO_COMPUTE_MEMBER_FOR_TAG,
DISPATCH_TIMEOUT, DISPATCH_FORWARD_FAILED, and
COMPUTE_MEMBER_DISCONNECTED now surface as retryable 503 uniformly across all
three callout kinds. Previously some of these — a missing compute member, most
visibly — fell through to a misleading 400 WORKFLOW_FAILED.
The workflow schema moves to 1.3; every existing 1.1 and 1.2 payload remains valid.
🔎 Type-directed search and criteria evaluation
Section titled “🔎 Type-directed search and criteria evaluation”cyoda-go had two independent Go implementations of predicate leaf-comparison —
one behind workflow criteria and the search fallback, one behind the plugin
pushdown paths — and they had drifted, so the same query could return different
results depending on which evaluator ran. Both now delegate every leaf operator
to a single shared kernel in the SPI; the SQL planners mirror it and stay guarded
by the cross-backend parity suite. The kernel implements Cyoda Cloud’s evaluation
model, documented in the new cyoda help predicates topic.
Observable changes:
- Same-type comparison. An operand is parsed against the field’s declared type(s), so a numeric-looking string and a JSON number behave identically and there is no cross-type coincidental matching.
- Precise numbers. Arbitrary-precision comparison replaces
float64coercion — correct beyond 2^53. LIKEis a real anchored glob (%,_,\-escape, whole-string, case-sensitive) on every backend, no longer wildcard-neutered on SQL.- Temporal data fields compare chronologically, across six subtypes with
resolution upscale/downscale. Meta-field temporal filters
(
creationDate/lastUpdateTime) now also accept a coarser operand such as a bare year. - Negatives are null-guarded.
NOT_EQUAL,NOT_CONTAINS,INOT_*and the rest evaluate to non-match on an absent ornullfield, rather than matching via!positive. BETWEEN_INCLUSIVEis fixed — it previously fell through to a regex evaluation onSearcher-backed stores instead of an inclusive range check.- Validation is parse-based: a
400is returned only when the operand parses into none of the field’s declared types, so some requests that used to fail now succeed and vice versa. - A field seen as both an object and a bare scalar is searchable via its scalar
type; a scalar operator against a pure-container path now returns
400 INVALID_FIELD_PATHinstead of silently matching nothing.
Alongside this, the schema classifier now detects LOCAL_DATE,
LOCAL_DATE_TIME, LOCAL_TIME, ZONED_DATE_TIME, YEAR, and YEAR_MONTH
rather than classifying every temporal string as STRING — which is also the
prerequisite for temporal range indexes in a backend that builds them.
🚧 Direct search is bounded-or-fail
Section titled “🚧 Direct search is bounded-or-fail”Direct (synchronous) search now caps the matched set rather than paging it: a
query matching more entities than limit allows returns
400 SEARCH_RESULT_LIMIT instead of a truncated prefix. A partial result was
indistinguishable from a complete one, which is the defect being closed. An exact
match at limit succeeds.
The default when limit is omitted is unchanged at 1000 — but it is now actually
applied. Previously the default was honoured only on the GetAll+match fallback
branch, while the Searcher pushdown branch used by all three OSS backends
treated an omitted limit as unbounded and returned the entire matched set.
limit=0, which used to mean an unbounded synchronous search, is now rejected
with 400 BAD_REQUEST.
Ordered top-N over a large model — sort plus a small limit — is no longer
available on the synchronous path. Async search covers it: it snapshots the full
matched set and pages over it.
Two failure modes that previously surfaced as an opaque 500 ticket are now
classified 400s: SEARCH_RESULT_LIMIT when a backend detects an over-cap
matched set, and the new SCAN_BUDGET_EXHAUSTED when a non-indexable residual
scan exceeds the backend’s row budget. Conditional delete also forwards a
classified 4xx from its delete-selection search instead of a 500.
🔁 Tx-aware search pushdown and trackingRead
Section titled “🔁 Tx-aware search pushdown and trackingRead”An in-transaction search no longer falls back to a full GetAll scan plus
in-memory filtering. The plugin-level Searcher honours the active transaction
directly and stays read-your-own-writes correct against its uncommitted writes,
with no full-model materialisation — memory and sqlite overlay the transaction
buffer on the committed stream, postgres runs the query natively on its own
pgx.Tx.
A new optional trackingRead boolean (default false) on the synchronous search
endpoints opts a search into recording its returned entities into the
transaction’s read-set, so a concurrent write to one of them aborts the
transaction at commit. This is entity-level, the same as GetAll, and still
offers no phantom-write-skew protection. The default is a plain snapshot read
recording nothing — a lighter read-set footprint than before. Async search does
not expose the flag, as it runs detached. See docs/CONSISTENCY.md §3c for the
updated fence guidance.
Separately, compute-node callback transaction-join now covers the gRPC search
RPCs. A callback presenting a valid tx-token on EntitySearch /
EntitySearchCollection previously had it silently ignored — the interceptor was
wired only for the write RPCs — so a processor’s writes joined the originating
transaction while its searches ran unjoined against last-committed state. That
asymmetry produced stale results with no error.
👤 Attribution for deferred and cascaded actions
Section titled “👤 Attribution for deferred and cascaded actions”When a user’s action set off a later or indirect workflow action, cyoda-go
recorded the follow-on against a service or system account, losing the human who
caused it. A cascade — a processor writing other entities in reaction to a user’s
transition — was recorded as the compute service account; a scheduled fire was
recorded as a fake "scheduler" user.
Attribution and authorization are now separate concerns. A follow-on action is
executed with system or service authority — nobody is impersonated and no
user permissions are borrowed — but attributed to the principal captured
server-side when the follow-on was created: the transaction’s origin for a
cascade, propagated unchanged through every joined write including a cross-node
proxied join, and the durable ArmedBy on the scheduled task for a timer fire.
Origin is platform-set only; no request field or worker input can set it.
Principals now carry an explicit kind — user, service, or system — instead
of being guessed from the presence of ROLE_M2M, a guess that was wrong in both
directions. GET /entity/{entityId}/changes gains attributedKind and
executedBy: {id, kind} per change, so a service-executed write is never
indistinguishable from a direct user action. The existing user field is
unchanged, and rows written before this release omit the two new fields entirely
rather than emitting null.
For compute-node authors, the new api/grpc/authctx helper exposes Type, ID,
and Roles readers plus Require(ce, role) — a fail-closed role gate returning
false for a nil event, absent or empty claims, or authtype == system.
🛑 Criterion stoppage reason
Section titled “🛑 Criterion stoppage reason”EntityCriteriaCalculationResponse.reason was declared on the wire but dead
end-to-end: a criteria compute node could not tell the engine why it returned
false. It is now live. A manual explicit transition rejected by its criterion
appends the reason to the 400 WORKFLOW_FAILED detail — the guaranteed,
backend-independent surface.
For automated-cascade and workflow-selection paths the reason is additionally
recorded durably on the state-machine audit trail:
TRANSITION_NOT_MATCH_CRITERION carries
{workflowName, transition, criterion, reason} in its data, and
WORKFLOW_SKIP carries {workflowName, reason}. Reasons are capped at 2 KiB;
an omitted reason defaults to "criterion did not match".
🐛 Correctness fixes
Section titled “🐛 Correctness fixes”- Depth-2 nested joins no longer deadlock the transaction. When a joined callback ran a transition whose own SYNC processor drove a further joined write on the same transaction, the third-level write blocked on the per-transaction gate the second-level callback still held while parked in its dispatch. The transaction hung for the full 30 s dispatch timeout and then failed. The gate is now released across every external dispatch and re-acquired before the buffer is touched again, generalising the invariant the owner path already upheld to every joined callback.
- gRPC keep-alive storm. The streaming server replied with a keep-alive to every inbound member keep-alive while also sending its own on a 10 s ticker. Against a client that likewise echoed, this produced a delay-free feedback loop pinning both processes at ~100% CPU indefinitely, with nothing above Debug in the logs. An inbound keep-alive is now liveness-only.
- Boolean search conditions on postgres no longer
500. The query planner bound a raw Goboolagainst a text-typeddoc->>'path'extraction, which pgx cannot encode. The operand is now rendered as its text form, matching what memory and sqlite already did. - Malformed criterion regexes are rejected at import. A non-compiling
MATCHES_PATTERNpattern imported successfully and then errored on every evaluation of that transition; it now fails at registration with400 VALIDATION_FAILED.
📚 Help and documentation
Section titled “📚 Help and documentation”- Plugins can contribute help topics.
help.RegisterOverlay(fs.FS)lets a storage plugin hand its embeddedcontent/tree to the help loader frominit(), mirroring the SPI storage-factory registration pattern, so a backend can document its own behaviour throughcyoda help. cyoda help config alllists everyCYODA_*variable as a table, with--format=jsonfor the machine-consumable form andGET /help/config/allover HTTP. It is assembled at request time from a root-side registry plus each registered plugin’sConfigVars(), so an out-of-tree backend’s variables appear with no root import. Newconfig.clusterandconfig.schedulersubtopics give those variables first-class discoverability.- gRPC entity PATCH is documented, along with
EntityModelSetUniqueKeysRequest; both were registered and tested but missing from the event-type catalogue that downstream docs consume. A parity test now pins the catalogue against the registered constants.
🛡️ Security
Section titled “🛡️ Security”Both advisories cleared this cycle required a version beyond what Dependabot proposed:
google.golang.org/grpcv1.81.1 → v1.82.1 (HIGH). The grouped Dependabot PR stopped at v1.82.0 and would have left the advisory open.getkin/kin-openapiv0.142.0 → v0.144.0 for GHSA-r277-6w6q-xmqw and GHSA-jpcw-4wr7-c3vq (CRITICAL, fail-open auth bypass). The advisories are inopenapi3filter, which cyoda-go uses only in its E2E conformance validator; the shipped binary uses kin-openapi for spec parsing and never for request authentication. Dependabot proposed v0.142.0, short of the fix.oapi-codegen/v2v2.7.0 → v2.7.1 (LOW), withapi/generated.goregenerated.
Every open CodeQL finding was also cleared. Two were genuine bugs: a path
traversal via a caller-supplied message id reaching os.Remove in the memory
plugin’s message store, and a typed-nil-in-interface guard that could never fire,
marshalling a dangling $ref to "null" in the help renderer. The
go/log-injection rule (49 sites) is excluded with a test pinning the rationale:
slog’s TextHandler quotes injected newlines, so the protection is a property
of the handler rather than of each call site.
🚀 Resources & getting started
Section titled “🚀 Resources & getting started”For installation guides, building from source, architecture, and running the engine locally or in production:
- Cyoda Hub (cyoda.dev) — the primary portal for the Cyoda ecosystem, community links, and major updates.
- Cyoda Documentation (docs.cyoda.net) — quick-starts, configuration schemas, API references, and operations guides.
- GitHub Project (github.com/Cyoda/cyoda-go) — source code, build requirements, and binary releases with cryptographic checksums under the v0.8.3 release tag.
💬 Feedback
Section titled “💬 Feedback”Found a bug, hit a parity gap, or have a feature request? Open an issue or start a thread in our GitHub Discussions — and feel free to join us on Discord. Thank you for building with Cyoda-Go!
📌 Footnote: why v0.8.3 is a patch that still breaks things
Section titled “📌 Footnote: why v0.8.3 is a patch that still breaks things”v0.8.3 is a patch release that carries breaking changes — listed in the
caution above. Pre-1.0 the minor component is the breaking-change signal and
features ship in patches; this release departs from that on the search and
authtype contracts. Read the breaking-changes list before upgrading rather
than inferring compatibility from the version number.
cyoda-go-spi ships its own breaking changes as v0.8.3. It is versioned on
an independent axis from the binary — see
COMPATIBILITY.md
for the supported combinations.