Skip to content
Settings

Cyoda-Go v0.8.4

One path grammar, one resolver, one search execution path — and a server boundary that stays up while it answers.

Released 9 September 2026 · 40 issues delivered

This is a consolidation release. Where cyoda-go had two implementations of the same idea, it now has one. A field path is written one way and resolved by one resolver on every surface — search, workflow criteria, grouping, sorting. A value is judged admissible by one test, whether it arrives on a write or is matched by a query. A search runs on one path, with the whole-model fallback that quietly made a rejected condition succeed deleted outright. Duplication is not a tidiness complaint here: every pair of implementations in this release had already drifted, and each drift showed up to a caller as a plausible, wrong 200.

Alongside that, the server boundary was hardened. Panics are contained rather than fatal, transactions are released on every exit path, PostgreSQL carries real ceilings, and every read path that used to load a whole model into memory now streams. Async search survives losing the node that was running the job.

This release changes more caller-visible behaviour than any before it. Read the breaking-changes callout below before upgrading.

  • One path grammar. A field path is JSON Path — $.amount, not amount — on search conditions, workflow criteria, groupBy, aggregation fields and sort keys alike, validated by one scanner at the boundary.
  • One resolver. A path’s meaning is decided by its syntax, not by the shape of the stored value it happens to meet. $.tags[*] addresses the elements of tags; it used to resolve to the array’s length.
  • Search has one path. The in-memory whole-model fallback is deleted. A condition that cannot be translated is a 400, not a full scan.
  • NOT is a real operator, declared in the API since the first import and answered 400 until now. Workflow schema moves to 1.4.
  • One type-admission test. A field holds a value when its declared type admits that value — asked directly, per value, on both the write side and the query side, so what can be stored is exactly what can be found.
  • Transaction lifecycle safety. Deferred rollback on every exit path, five PostgreSQL ceilings, pool-acquire timeouts, and a new retryable 503 STORAGE_UNAVAILABLE on all 52 storage-backed operations.
  • Panic containment. gRPC and every HTTP route recover; a panic in engine or store work withdraws the node from service instead of killing the process or silently continuing.
  • Nothing materialises a model. Paged list reads, streamed async-search results, streamed delete selection, purpose-built history reads.
  • Async search survives node loss. Bounded worker pool, per-tenant admission, heartbeats, fenced claim epochs, and orphaned jobs re-executed rather than failed.
  • The write-visibility contract is stated. A 2xx write is visible to every subsequent read on every node; waitForConsistencyAfter is retired because it could toggle nothing.

cyoda-go accepted several spellings of a field path and resolved them in several places. The spellings disagreed, and the disagreement was invisible: each surface answered 200 with whatever its own resolver produced.

A path is now written as JSON Path and validated once, at the boundary, by a scanner shared with the pushdown translator:

jsonPath = "$." segment ( "." segment )*
segment = name subscript*
name = 1*( ALPHA / DIGIT / "_" / "-" ) ; ASCII only
subscript = "[" ( "*" / 1*DIGIT ) "]" ; the digit run must fit an int32

What that closes, in the order it bit hardest:

  • A trailing wildcard addresses elements, not a count. $.tags[*] EQUALS "red" compared "red" against the number of tags and never matched. Multiple array hops were broken by the same arithmetic, with or without a trailing wildcard: $.matrix[*][*], $.a[*].b[*] and $.orders[*].lines[*].sku all compared against a nested array.
  • A bare path is rejected. amount used to be read as $.amount by the in-memory evaluator, so the pushdown translator refused it, the request fell back to a full model scan, and the caller got correct-looking results off a path the query planner had declined.
  • Malformed subscripts are rejected. The path used to be scanned only as far as the first [. $.a[-1], $.a[0:2], $.a[?(@.x)], $.a[0];DROP and an unclosed bracket all classified as “not pushdownable” and fell through to an evaluator that resolves none of them — an empty page for a field that exists.
  • A positional path resolves. $.arr[0] missed on three independent lookups and answered an empty page while its wildcard twin worked. Two spellings of one path disagreed.
  • Workflow criteria obey the same grammar, enforced at import. A criterion on amount used to import cleanly and then guard a transition that silently never fired.

Grouped stats, aggregation fields and sort keys share the same scanner minus the subscript production, so those three surfaces cannot drift from it again. $.-prefix handling is now uniform across HTTP and gRPC: a gRPC orderBy on $.city used to be looked up as $.$.city. The grammar and its addressing rules are on the searching entities page; docs/cloud-parity/path-grammar.md carries the parity contract.

A condition that could not be translated into a backend query used to fall back to loading the model and filtering in Go. Every call site treated a translation failure as “scan instead”, so a path the translator rejected was still answered — with results, at 200, off a plan the caller never asked for.

The fallback is deleted. A condition that does not translate is 400 (INVALID_CONDITION, or INVALID_FIELD_PATH for a path-shaped failure). No client-reachable request changes status, because the boundary grammar and the translator now share one path parser and one operator set: validated input always translates.

Conditional DELETE /entity/{entityName}/{modelVersion} and grouped statistics each carried a fallback of the same shape, and both are gone too. One malformed condition used to be answered three ways — a 400 from search, a served result from each of the other two. All three refuse it now, for the same reason.

EntityStore gains Search and Iterate as required methods and loses GetAll/GetAllAsAt. There is no whole-model read anywhere in the engine. Grouped statistics therefore always has an execution path, and its 501 NOT_IMPLEMENTED_BY_BACKEND is retired.

Two other search-shaped defects go with it. Async search now translates the condition before it persists the job, so a condition no backend can execute is refused at submission instead of failing in the background. And a search whose model schema cannot be loaded now fails with a 500 and a ticket rather than skipping validation and answering: with no fields map, eight of the twenty-six operators collapse to a non-match while the other eighteen keep matching, so the short page was not merely unvalidated, it was wrong.

NOT has been declared in GroupConditionDto.operator’s OpenAPI enum since the initial import while the server answered 400 for it. It is now implemented end to end — search, grouped stats, conditional delete, and workflow and transition criteria.

It takes exactly one child condition; zero or two-or-more is 400 INVALID_CONDITION (400 VALIDATION_FAILED at import). Over a wildcard-addressed list it is a universal quantifier where the leaf it wraps is existential: NOT($.tags[*] EQUALS "red") matches when no element equals "red", a different question from $.tags[*] NOT_EQUAL "red". NOT over an empty list, an explicit null or an absent field matches, because the wrapped leaf is false. It is residual-only — no backend pushes it into its own query language.

Two adjacent answers were wrong and are now correct:

  • An unsatisfiable comparison follows operator polarity. $.n NOT_EQUAL 12.5 on a field declared INTEGER used to return nothing. It now returns every entity holding a number at n, because no integer equals 12.5 — the answer PostgreSQL gives for 5::int <> 12.5. This widens conditional delete as well as search.
  • A criterion naming a field the model does not declare aborts the save it evaluates, 400 WORKFLOW_FAILED, rolled back. It used to evaluate to “not satisfied”, so a misspelled field name meant a transition that silently never fired.

Workflow schema version bumps 1.3 → 1.4. Schemas 1.1 through 1.4 are all accepted; nothing is retired. An integrator whose CI pins GET /help/workflows/schema-version/versions to "1.3" must update the pin. See docs/cloud-parity/negation.md.

🧬 The model is one thing, and every part of the system reads it the same way

Section titled “🧬 The model is one thing, and every part of the system reads it the same way”

The model layer, the write validator and the search kernel each had their own idea of what a field declares and what it can hold. Each disagreement produced a value that could be stored but not found, or found but not stored.

  • A field holds a value when its declared type admits it — a direct, per-value test, on both sides. Ingestion used to compute a value’s label (INTEGER, LONG, a temporal subtype) and ask whether that label was assignable to the declaration; search classified the stored value the same way, and the two classifications did not always land on the same side of the line. A DOUBLE field now accepts 2147483648 without widening the model, and a STRING field holds "2026-03-01" with no schema change at any changeLevel. See docs/cloud-parity/numeric-type-admission.md.
  • A schema node holds the set of kinds it was observed as. The persisted form gains "kinds"; a monomorphic node still writes "kind" and serialises byte-identically, so no model needs migrating. A stored node under the old single-label form now restores every branch its payload carries rather than the one the label happened to name.
  • A value whose kind the field does not declare is rejected. A field declared STRING used to accept an array or an object and store it, while correctly refusing a number. The reverse direction was always enforced, so the hole was one-directional.
  • An array’s length is not part of the model. The discovery-time “widest array seen” statistic is gone, along with the write-path width comparison and the (T x N) decoration SIMPLE_VIEW rendered from an in-memory tree but never from a persisted one. An export now describes the model rather than the route the model took into memory. ARRAY_LENGTH keeps its place as the floor of the ladder: the level that permits no schema change at all.
  • Model field names must be addressable. A field name is accepted only if it is a valid path segment. The model layer used to record any JSON key while the query layer could address only this charset, so a document could establish a field nothing could ever search.
  • The model export describes every branch a field declares. An array of arrays rendered as .m[*]: NULL instead of naming the elements at .m[*][*]; a field observed as both scalar and container showed only the container. Two models that enforce differently rendered identically. JSON_SCHEMA now renders a kind union as anyOfoneOf rejected values the model admits whenever two branches rendered the same shape.
  • A JSON array posted to the sample-data import is a collection of sample documents. It used to register a model describing an array at the root: SIMPLE_VIEW rendered {}, and the model then refused the very documents it was derived from.

The rewritten entity model export reference carries the new wire format for both converters.

An entity write now releases its transaction on every exit path, including a panic. Previously a panic between begin and commit left the transaction neither committed nor rolled back with its pooled connection never returned; repeated, that exhausts the pool and the node stops serving. The workflow engine likewise releases the segments it opens itself — an ordinary compute-node failure mid-cascade was enough to leak one, permanently on memory and sqlite.

PostgreSQL gains five configurable ceilings, all defaulting on:

Variable Default Bounds
CYODA_POSTGRES_STATEMENT_TIMEOUT 5m any single statement
CYODA_POSTGRES_IDLE_IN_TX_TIMEOUT 5m idle gap inside an open transaction
CYODA_POSTGRES_ACQUIRE_TIMEOUT 10s waiting for a pooled connection
CYODA_POSTGRES_MIGRATE_LOCK_TIMEOUT 5m migration advisory lock
CYODA_POSTGRES_SEARCH_STATEMENT_TIMEOUT 30m async-search statements

Each takes a Go duration, 0 disables that limit, and a malformed value fails startup rather than falling back to the default. SQLSTATE 57014 and 25P03 are classified rather than surfacing as unexplained errors.

The transaction reaper and CYODA_TX_TTL, CYODA_TX_REAP_INTERVAL and CYODA_TX_OUTCOME_TTL are removed: nothing ever registered a transaction with the reaper, so the TTL they advertised was never enforced.

A new retryable 503 STORAGE_UNAVAILABLE covers pool exhaustion, a transaction aborted by the idle ceiling, and a connection going away. It is declared on all 52 storage-backed operations in api/openapi.yaml. Relatedly, a storage outage no longer answers 404 Not Found — async-search status and results, trusted-key operations, the audit transaction lookup and several entity reads used to collapse any store error into a not-found result, telling a client “it does not exist” and stopping the retry.

Commits are now shielded from a client disconnect or an expired deadline arriving mid-commit, so a deadline can no longer produce an in-doubt “client sees failure but the write is durable” outcome.

Panic recovery now covers the gRPC server (unary and stream) and every HTTP route, where it previously covered only the / catch-all — so a gRPC panic killed the process and an HTTP panic on a specific route dropped the connection with no ProblemDetail and no ticket. Recovery is the outermost HTTP layer, covering CORS and cluster-routing middleware and the admin server.

A recovered panic at any of the four sites that run engine or store work — the two request doors, the async-search goroutine, the scheduler’s dispatch goroutine — permanently marks the node unhealthy: GET /health reports 503 DOWN and /readyz reports 503, so Kubernetes drops the pod from its Service within ~10-15s. The node’s state is unverified, so withdrawing it is deliberate. Know the bound: peer-forwarded work keeps arriving, established connections stay open, and nothing restarts the node, since /livez is unchanged. Read the ticket from the log and replace the pod.

Each compute member’s stream now has exactly one writer goroutine draining an outbox. A frozen compute node is evicted within CYODA_KEEPALIVE_TIMEOUT of inbound silence or when one write has stalled that long, so a node that keeps pinging while its application is stuck is caught too. CYODA_KEEPALIVE_INTERVAL and CYODA_KEEPALIVE_TIMEOUT were parsed and ignored; they now reach the gRPC server.

New HTTP receive-side timeouts, on both the API and admin servers: CYODA_HTTP_READ_HEADER_TIMEOUT (10s), CYODA_HTTP_READ_TIMEOUT (5m), CYODA_HTTP_IDLE_TIMEOUT (2m). CYODA_HTTP_WRITE_TIMEOUT exists and ships disabled: the server imposes no time budget on work.

PostgreSQL pool saturation is now observable — seven cyoda_storage_pool_* metrics, always on at /metrics.

A 4xx error body no longer scales with the size of a malicious request. An entity write with hundreds of thousands of undeclared fields renders the first 32 failures plus a summary; a rejected condition operand is truncated before it is echoed back.

Every read path that used to load a whole model into memory now streams:

  • Paged entity-list reads. GET /entity/{entityName}/{modelVersion} pages at the store instead of loading the model and slicing in Go.
  • Streamed async-search results, saved incrementally as the scan runs.
  • Streamed delete selection, on both the conditional and unconditional forms.
  • Purposed history reads. GET /entity/{entityId}/changes and the audit transaction lookup use metadata-only reads bounded by one entity’s own version history.
  • In-transaction reads on sqlite and memory serve Iterate, GetPage, Count, CountByState and DeleteAll from one overlay cursor rather than a merged copy of the model.

The SQLite backend opens a dedicated read connection pool so a long undrained scan cannot starve concurrent writes. Note the memory cost: CYODA_SQLITE_CACHE_SIZE (default 64000 KiB) is per connection, so the resident ceiling is now (readers + 1) × CYODA_SQLITE_CACHE_SIZE — on an 8-CPU host with defaults, ≈ 562 MiB where it was ≈ 62.5 MiB. CYODA_SQLITE_READER_POOL_SIZE sizes the pool (default GOMAXPROCS clamped to 4..8). GOMAXPROCS follows the CPU quota and is blind to the memory limit, so a container generous on cores and tight on memory must lower this.

The server no longer imposes a scan budget on search: sqlite’s residual-scan budget, CYODA_SQLITE_SEARCH_SCAN_LIMIT and the SCAN_BUDGET_EXHAUSTED code are removed, closing the divergence with memory and postgres. Bounding search time is the caller’s job, using timeoutMillis or cancellation. Bounding search memory is the server’s, and every search path now streams.

Two throughput fixes ride along: a model’s parsed schema is cached alongside its descriptor (on a 1000-field model, criterion evaluation drops from 1.84 ms and 12,400 allocations to 12 µs and 91), and the search leaf evaluator prepares once per query instead of once per candidate row.

Async search moves from one goroutine per submission to a bounded worker pool, with a retryable 503 SEARCH_QUEUE_FULL once workers and queue are both exhausted. Five new env vars, all validated at startup rather than silently clamped: CYODA_SEARCH_ASYNC_WORKERS (8), CYODA_SEARCH_ASYNC_QUEUE (256), CYODA_SEARCH_ASYNC_MAX_PER_TENANT (8), CYODA_SEARCH_JOB_HEARTBEAT_INTERVAL (15s), CYODA_SEARCH_JOB_STALE_AFTER (5m).

Plan for the per-tenant cap. It is on by default and counts queued and running jobs together, so a single-tenant deployment’s accepted-in-flight ceiling drops from workers + queue (264) to 8: a 50-submission burst that was accepted in full now gets 8 accepted and 42 answered 503. That is the point — the cap is what stops one tenant locking every other tenant out — but a single-tenant deployment sees only the cost. Raise it, or set 0 to restore first-come-first-served.

A job whose owning node is lost is now re-executed, not failed. Every job carries a claim epoch, and heartbeats, streamed result saves and the terminal status write are all fenced against it, so an executor that was reaped and later recovers has its next write rejected instead of corrupting a result set another node has taken over. The reaper clears a claimed job’s partial results and re-runs it on a live node as at its originally stored point in time, so a client observes only a longer RUNNING span. A graceful shutdown releases in-flight jobs immediately for reclaim, and a released claim never counts against the attempt cap, so a rolling restart of any length is free. CYODA_SEARCH_JOB_MAX_ATTEMPTS (default 3) bounds executor losses before the job is failed. See docs/cloud-parity/async-job-node-failure-resilience.md.

Cancelling a job no longer leaves it permanently un-reapable: CancelAsync called a generic status update that never stamped a finish time, and the reaper only removes terminal jobs that have one, so every cancelled job accumulated for the life of the process.

🎛️ Transaction-control parameters are honored

Section titled “🎛️ Transaction-control parameters are honored”

Three parameters that were accepted and silently ignored now do what they say:

  • transactionTimeoutMillis on all seven entity write operations and newMessage. It bounds time-to-first-commit; exceeding it rolls back and fails 408 TRANSACTION_TIMEOUT with nothing committed.
  • transactionSize on deleteEntities and deleteMessages. Matching items are deleted in independent batches; deleteEntities reports per-id errors in deleteResult.idToError rather than retrying, and batches already committed before a later failure stay committed.
  • timeoutMillis on searchEntities, with 408 SEARCH_TIMEOUT and no partial results, enforced uniformly across memory, sqlite and postgres.

All three are rejected with 400 on a request that joins an open transaction, where honoring them is unsafe. gRPC mirrors the same semantics.

A batched delete that can never finish now fails with a new retryable 409 DELETE_NOT_CONVERGED instead of running forever: with transactionSize set and no pointInTime, the request re-selects before every batch, and if entities are created at least as fast as they are removed, that pass never comes up empty.

A successful write response already means the write is visible to every subsequent read on every node, so waitForConsistencyAfter could toggle nothing. It is retired from the seven entity write operations; a request that still carries it is accepted and the parameter ignored. The contract, and what every backend must do to meet it, is recorded in docs/cloud-parity/write-visibility-contract.md.

The whole-model delete now honors pointInTime and verbose on both doors. Its fast path ignored the instant — deleting entities created after it — and returned an empty id list beside a non-zero count. The gRPC response’s entityIds is populated for the first time, and the inert pageSize field is removed from EntityDeleteAllRequest.

On a model with several imported workflows, every operation after creation ran the wrong workflow’s definition. A named transition, a loopback re-evaluation and a scheduled transition firing all resolved the workflow by “the first active definition that declares the entity’s current state”, ignoring the entity’s selection criterion. Definitions on one model usually share state names, which is the normal shape for a per-kind machine. In that case the resolver always picked the first declared workflow, for every entity, applying the wrong guards, processors and target states. It did so silently, and it failed open. Selection at creation was correct, which is why the binding looked right in the creation audit. All four doors now resolve through the documented criterion rules on every call.

Integrators: because selection is re-evaluated per call, an entity whose payload changes can re-bind to a different definition. Prefer selection criteria that stay true for an entity’s whole lifetime, and that read fields a caller cannot rewrite in the same request — the criterion is evaluated against the payload of the request being served, so where definitions differ in what they permit, the selection field is a security control.

GET /entity/{entityId}/transitions no longer answers from the default workflow when a selection criterion cannot be evaluated. That was a wrong-but-available answer; it now fails the request. The same read was also writing WORKFLOW_SKIP/WORKFLOW_FOUND audit events against an empty transaction id despite intending not to.

A workflow processor’s returned data is now governed by the model exactly as a client’s write is. A processor could previously write content no backend could store, or fields the model does not declare — producing an entity the API would return but then refuse to accept back on a PUT. A processor that writes a field outside its model now needs that model’s changeLevel set, or the field declared.

A criterion carrying an operator nobody can evaluate now fails the save rather than short-circuiting past it, and a model-store outage during criterion evaluation is no longer masked by a structural error on a sibling conjunct.

A family of payloads that were “valid JSON, unstorable” reached the store and came back as 500 with a support ticket on PostgreSQL while memory and sqlite accepted them — so the set of storable values depended on the backend. All are now rejected at the boundary with 400, on every backend and on both HTTP and gRPC:

  • A NUL (U+0000) anywhere in the payload.
  • Unpaired UTF-16 surrogates and invalid UTF-8. The guard reads the raw request bytes, which is load-bearing: Go’s decoder rewrites both forms to U+FFFD, so validating the decoded value cannot see them and re-serialising would store a character the client never sent.
  • A name repeated within one object. It was read as the last occurrence by schema validation, the GET response and unique-key computation, and as the first by criteria, search and grouped statistics — on the same bytes in the same request.
  • Trailing content after a valid JSON value ({"x":1}}}).
  • A number outside PostgreSQL’s numeric range.

The gRPC entity API now carries the client’s payload bytes verbatim to the same guard; it previously decoded and re-marshalled before validation. All five gRPC entity write events enforce the full guard set.

Separately, an empty entity payload no longer bricks the entity and its whole model’s listing on PostgreSQL: {} was accepted with 200 and then failed every subsequent read with 500, including the model-wide listing, because one unreadable row failed the whole page.

A processor returning {"data":null} no longer panics and leaks a database connection.

  • POST /api/oauth/oidc/providers/reload no longer destroys the JWKS cache it is documented to refresh. The reload rebuilt the provider list but installed empty key sources and never re-warmed them, so every federated token failed 401 unknown kid until a process restart — including providers that were healthy before the call. Surviving key sources are now carried across the rebuild and every loaded provider is force-warmed, on the receiving node and on every broadcast peer.
  • A provider whose IdP was unreachable at startup no longer stays keyless for the life of the process. Failed warm-ups are retried every 30 seconds.
  • Trusted-key revocation propagates across the cluster, and OIDC providers converge after a dropped gossip broadcast.
  • Cross-node dispatch fails over to a peer rather than failing the write.

A backend diverging from the others on the same contract is a defect, not an accepted difference. This release closes a long list of them:

  • CompareAndSave compares the expected transaction ID literally on every backend, and rejects an empty one. The empty string named three states at once — never written, deleted, and written outside any transaction — so “create only” could silently overwrite an entity that exists. Compare-and-save can no longer create or resurrect an entity: Save is how you create, and Save is what unstages a delete.
  • Concurrent non-transactional compare-and-saves yield exactly one winner on all three backends; the check and the write are one atomic step.
  • A write inside a transaction carries that transaction’s ID on every backend. memory and sqlite stamped only at commit; postgres honoured a caller-supplied value. A row cannot claim it was committed by a transaction that did not commit it.
  • A pointInTime read inside a joined transaction is committed-only on PostgreSQL. It ran on the caller’s own transaction connection, so a snapshot read answered with that transaction’s uncommitted writes. Memory and sqlite already behaved this way.
  • PostgreSQL’s in-Go residual filter no longer sees the internal _meta block, so a condition naming a data path under _meta no longer matches there and on no other backend.
  • PostgreSQL text comparisons use COLLATE "C", matching the ordering the search kernel and ORDER BY already use. On a database whose default collation is not byte order, a text range query can now return a different — correct — set of rows.
  • sqlite numbers a new entity’s first version 1, matching memory and postgres. Existing entities keep their stored numbers.
  • A client disconnect aborts in-flight per-item work on memory and sqlite, matching postgres.
  • sqlite’s message batch-delete chunks its IN list instead of breaking on the driver’s 32766 bound-variable limit.

Fourteen new error help topics — including STORAGE_UNAVAILABLE, TRANSACTION_TIMEOUT, SEARCH_TIMEOUT, SEARCH_QUEUE_FULL and DELETE_NOT_CONVERGED — plus ten grouped-statistics codes that were inline string literals with no constant and no topic, so cyoda help errors <CODE> answered 404 for every one of them. POLYMORPHIC_SLOT and SCAN_BUDGET_EXHAUSTED are retired.

Twenty new contract documents under docs/cloud-parity/ (32 in total) state the behaviours Cyoda Cloud mirrors, including path-grammar.md, operator-semantics.md, negation.md, numeric-type-admission.md, model-kind-enforcement.md, write-visibility-contract.md and search-has-one-path.md.

  • Cross-tenant timestamp leak closed. GetSubmitTime was the only transaction-lifecycle method without a tenant check: a caller supplying another tenant’s transaction ID — reachable via GET /entity/{id}/transitions?transactionId= — could learn that transaction’s submit time or its in-flight state. All three storage backends now reject cross-tenant lookups before any state-dependent response, and the endpoint answers the same 400 for a foreign transaction ID as for a nonexistent one. The SQLite submit_times table gains a tenant_id column (migration 000005, drop-and-recreate; rows carry a 1-hour TTL).
  • A 4xx body no longer amplifies with a malicious request. Three response-body amplification paths are bounded, and a decoding-contract violation now answers 5xx with a ticket instead of echoing an internal decoding instruction or a Go type name into a 400.
  • A raw driver error is no longer interpolated into an async-search 400 body, where it could carry connection detail.
  • A bare context cancellation escaping workflow evaluation is a sanitized 500, not a 400 carrying the error’s own text as domain detail.
  • Three permissive defaults on an unreachable parse error are now fail-closed in each of the memory, sqlite and postgres plugins.
  • Workflow selection criteria are a security control. Because selection is re-evaluated per call against the payload of the request being served, prefer criteria reading fields a caller cannot rewrite in the same request.

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.4 is a patch that still breaks things

Section titled “📌 Footnote: why v0.8.4 is a patch that still breaks things”

v0.8.4 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 path grammar, the search execution contract, the model’s type-admission rules, and the SPI. 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.4. It is versioned on an independent axis from the binary — see COMPATIBILITY.md for the supported combinations.