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.
✨ Highlights
Section titled “✨ Highlights”- One path grammar. A field path is JSON Path —
$.amount, notamount— 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 oftags; 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. NOTis a real operator, declared in the API since the first import and answered400until 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_UNAVAILABLEon 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
2xxwrite is visible to every subsequent read on every node;waitForConsistencyAfteris retired because it could toggle nothing.
🔍 Details
Section titled “🔍 Details”🧭 One path grammar, one resolver
Section titled “🧭 One path grammar, one resolver”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 onlysubscript = "[" ( "*" / 1*DIGIT ) "]" ; the digit run must fit an int32What 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[*].skuall compared against a nested array. - A bare path is rejected.
amountused to be read as$.amountby 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];DROPand 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
amountused 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.
🔎 Search has one execution path
Section titled “🔎 Search has one execution path”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, and the answers it exposed
Section titled “🚫 NOT, and the answers it exposed”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.5on a field declaredINTEGERused to return nothing. It now returns every entity holding a number atn, because no integer equals12.5— the answer PostgreSQL gives for5::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. ADOUBLEfield now accepts2147483648without widening the model, and aSTRINGfield holds"2026-03-01"with no schema change at anychangeLevel. Seedocs/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
STRINGused 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)decorationSIMPLE_VIEWrendered 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_LENGTHkeeps 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[*]: NULLinstead 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_SCHEMAnow renders a kind union asanyOf—oneOfrejected 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_VIEWrendered{}, 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.
🛟 Transaction and connection lifecycle
Section titled “🛟 Transaction and connection lifecycle”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.
🛡️ Server-boundary resilience
Section titled “🛡️ Server-boundary resilience”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.
🌊 Nothing materialises a model
Section titled “🌊 Nothing materialises a model”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}/changesand 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,CountByStateandDeleteAllfrom 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 operational hardening
Section titled “⏳ Async search operational hardening”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:
transactionTimeoutMillison all seven entity write operations andnewMessage. It bounds time-to-first-commit; exceeding it rolls back and fails408 TRANSACTION_TIMEOUTwith nothing committed.transactionSizeondeleteEntitiesanddeleteMessages. Matching items are deleted in independent batches;deleteEntitiesreports per-id errors indeleteResult.idToErrorrather than retrying, and batches already committed before a later failure stay committed.timeoutMillisonsearchEntities, with408 SEARCH_TIMEOUTand 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.
📣 The write-visibility contract
Section titled “📣 The write-visibility contract”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.
🔀 Workflow correctness
Section titled “🔀 Workflow correctness”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.
🧱 Payload integrity
Section titled “🧱 Payload integrity”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
GETresponse 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
numericrange.
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.
🔐 Auth and cluster caches
Section titled “🔐 Auth and cluster caches”POST /api/oauth/oidc/providers/reloadno 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 failed401 unknown kiduntil 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.
🐘 Storage-backend conformance
Section titled “🐘 Storage-backend conformance”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:
CompareAndSavecompares 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:Saveis how you create, andSaveis 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
pointInTimeread 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
_metablock, so a condition naming a data path under_metano longer matches there and on no other backend. - PostgreSQL text comparisons use
COLLATE "C", matching the ordering the search kernel andORDER BYalready 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
INlist instead of breaking on the driver’s 32766 bound-variable limit.
📚 Help and documentation
Section titled “📚 Help and documentation”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.
🛡️ Security
Section titled “🛡️ Security”- Cross-tenant timestamp leak closed.
GetSubmitTimewas the only transaction-lifecycle method without a tenant check: a caller supplying another tenant’s transaction ID — reachable viaGET /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 same400for a foreign transaction ID as for a nonexistent one. The SQLitesubmit_timestable gains atenant_idcolumn (migration 000005, drop-and-recreate; rows carry a 1-hour TTL). - A
4xxbody no longer amplifies with a malicious request. Three response-body amplification paths are bounded, and a decoding-contract violation now answers5xxwith a ticket instead of echoing an internal decoding instruction or a Go type name into a400. - A raw driver error is no longer interpolated into an async-search
400body, where it could carry connection detail. - A bare context cancellation escaping workflow evaluation is a sanitized
500, not a400carrying 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.
🚀 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.4 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.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.