﻿# 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

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

## 🔍 Details

### ⏰ 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

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

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 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 `400`s: `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`

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

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

`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

- **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`.

### 📚 Help and documentation

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

This release tightens several contracts. Review these before upgrading:

- **Direct search fails instead of truncating.** A synchronous search matching
  more than the effective `limit` returns `400 SEARCH_RESULT_LIMIT`. Since the
  omitted-`limit` default of 1000 is now genuinely applied, a query that
  previously returned a truncated page — or, on a `Searcher` backend, an
  unbounded result set — now fails. Narrow the condition, raise `limit` (maximum
  10000), or move to async search.
- **`limit=0` is rejected.** It previously meant an unbounded synchronous search;
  it now returns `400 BAD_REQUEST`. gRPC rejects `limit < 1` for the same reason.
- **Ordered top-N is gone from synchronous search.** Use async search.
- **Predicate evaluation is same-type.** Cross-type coincidental matches no
  longer occur, negatives no longer match null or absent fields, `LIKE` is a real
  anchored glob, and validation is parse-based — so a query's result set, or
  whether it validates at all, may change. See `cyoda help predicates`.
- **CloudEvent `authtype` values changed.** The Auth Context attribute now emits
  `user`, `service`, or `system`, driven by the principal's explicit kind.
  `service_account` is retired. Compute nodes switching on the old string must be
  updated. The attribute is always present and faithful: an unset or
  unrecognized kind fails the callout dispatch rather than emitting a normalized
  value.
- **Processor `config.attachEntity` defaults to `true`.** A processor omitting
  the field is imported with `attachEntity: true`, matching `schedule.function`
  and the criterion `function` callout. Existing workflows that omit it will
  start attaching the entity payload on re-import. Set `attachEntity: false`
  explicitly to opt out; compute nodes that ignore the payload are unaffected.
- **`TransitionScheduleDto.delayMs` is no longer `required`.** A `function`
  schedule carries no `delayMs`, so consumers of the workflow-export response
  must treat it as optional on a 1.3+ schedule. A pre-1.3 exported workflow
  always has it.
- **Malformed criterion regexes are rejected at import** rather than at
  evaluation. No previously-working configuration is affected — a non-compiling
  pattern always errored at evaluation time.
- **SPI (plugin authors):** `Searcher.Search` must fail rather than truncate when
  `SearchOptions.Limit` is exceeded, and must treat `Limit <= 0` as unbounded
  without substituting its own default. `MergePage` is renamed `MergeBounded` and
  drops its `offset` parameter; `SearchOptions.Offset` is removed outright.
  Out-of-tree plugins must be rebuilt against `cyoda-go-spi v0.8.3`.

For the complete and authoritative list, consult the
[CHANGELOG](https://github.com/Cyoda/cyoda-go/blob/main/CHANGELOG.md#083--2026-07-27).

## 🛡️ Security

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.

## 🚀 Resources &amp; getting started

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

- [**Cyoda Hub (cyoda.dev)**](https://cyoda.dev) — the primary portal for the
  Cyoda ecosystem, community links, and major updates.
- [**Cyoda Documentation (docs.cyoda.net)**](https://docs.cyoda.net) —
  quick-starts, configuration schemas, API references, and operations guides.
- [**GitHub Project (github.com/Cyoda/cyoda-go)**](https://github.com/Cyoda/cyoda-go)
  — source code, build requirements, and binary releases with cryptographic
  checksums under the
  [v0.8.3 release tag](https://github.com/Cyoda/cyoda-go/releases/tag/v0.8.3).

## 💬 Feedback

Found a bug, hit a parity gap, or have a feature request? Open an issue or
start a thread in our
[GitHub Discussions](https://github.com/Cyoda/cyoda-go/discussions) — and
feel free to join us on
[Discord](https://discord.com/invite/95rdAyBZr2). Thank you for building with
Cyoda-Go!

## 📌 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`](https://github.com/Cyoda/cyoda-go/blob/main/COMPATIBILITY.md)
for the supported combinations.