Skip to content
Settings

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.

  • 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.function calls 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 limit now fails with 400 SEARCH_RESULT_LIMIT instead 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.

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 float64 coercion — correct beyond 2^53.
  • LIKE is 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 or null field, rather than matching via !positive.
  • BETWEEN_INCLUSIVE is fixed — it previously fell through to a regex evaluation on Searcher-backed stores instead of an inclusive range check.
  • Validation is parse-based: a 400 is 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_PATH instead 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 (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.

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".

  • 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 Go bool against a text-typed doc->>'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_PATTERN pattern imported successfully and then errored on every evaluation of that transition; it now fails at registration with 400 VALIDATION_FAILED.
  • Plugins can contribute help topics. help.RegisterOverlay(fs.FS) lets a storage plugin hand its embedded content/ tree to the help loader from init(), mirroring the SPI storage-factory registration pattern, so a backend can document its own behaviour through cyoda help.
  • cyoda help config all lists every CYODA_* variable as a table, with --format=json for the machine-consumable form and GET /help/config/all over HTTP. It is assembled at request time from a root-side registry plus each registered plugin’s ConfigVars(), so an out-of-tree backend’s variables appear with no root import. New config.cluster and config.scheduler subtopics 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.

Both advisories cleared this cycle required a version beyond what Dependabot proposed:

  • google.golang.org/grpc v1.81.1 → v1.82.1 (HIGH). The grouped Dependabot PR stopped at v1.82.0 and would have left the advisory open.
  • getkin/kin-openapi v0.142.0 → v0.144.0 for GHSA-r277-6w6q-xmqw and GHSA-jpcw-4wr7-c3vq (CRITICAL, fail-open auth bypass). The advisories are in openapi3filter, 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/v2 v2.7.0 → v2.7.1 (LOW), with api/generated.go regenerated.

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.

For installation guides, building from source, architecture, and running the engine locally or in production:

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.