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

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

## 🔍 Details

### 🧭 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 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](/build/searching-entities/#how-a-field-path-is-written)
page; `docs/cloud-parity/path-grammar.md` carries the parity contract.

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

`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

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 `anyOf` — `oneOf` 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](/reference/entity-model-export/)
carries the new wire format for both converters.

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

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

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

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.

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

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

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.

### 🔐 Auth and cluster caches

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

### 🐘 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:

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

### 📚 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`.

This list is long. The short version: if you send conditions, criteria or
`groupBy` paths, re-read them against the new grammar before upgrading.

**Paths and conditions**

- **A field path must be JSON Path.** `amount` is rejected; write `$.amount`.
  Bracket-quoted access (`$['x']`), empty or trailing segments, and any
  character outside `ALPHA / DIGIT / _ / -` are rejected. Applies to search
  conditions, workflow criteria, `groupBy`, aggregation fields and sort keys.
- **A trailing `[*]` addresses elements, not the array's length.** A search that
  returned an empty page now returns matches; a criterion that never fired now
  fires, so entities sitting before a guarded transition will advance on their
  next save. In the other direction, a comparison that held against the length
  no longer matches. There is no path spelling for an array's length.
- **Malformed array subscripts are rejected** — slices, unions, negative
  indices, filter expressions, unclosed brackets, trailing junk.
- **A trailing wildcard on an array of pure objects with a scalar operand is
  `400 INVALID_FIELD_PATH`.** Navigate to the leaf sub-path.
- **An `array` clause's `jsonPath` must carry a trailing `[*]`**, and its
  `values` are now type- and shape-checked.
- **A subscripted path is rejected on `groupBy`, aggregation fields and sort
  keys** on every backend.
- **An unknown `operatorType` is `400 INVALID_CONDITION`** on every surface, and
  fails workflow import.
- **A group condition's operator must be exactly `AND`, `OR` or `NOT`**,
  case-sensitive.
- **String and pattern operators on `creationDate`/`lastUpdateTime` are
  `400 INVALID_CONDITION`.** They previously answered `200` with results that
  depended on which evaluator served the request.
- **A bare path no longer matches an array's elements, and a `[*]` path no
  longer matches a scalar.** What a path addresses is decided by its syntax.

**Search behaviour**

- **`LIKE` is matched as a glob, not rewritten into a regex.** `%` and `_` now
  match a newline; a backslash escape is now literal, so `LIKE "\d"` matches the
  character `d` rather than any digit. Any operand carrying a backslash before an
  ordinary character changes meaning. An unpaired trailing `\` is now rejected.
- **An invalid `LIKE` or `MATCHES_PATTERN` operand is rejected at the boundary**
  with `400`, where it previously returned `200` and then failed or matched
  nothing.
- **`NOT_EQUAL` against an unsatisfiable operand now matches.** `$.n NOT_EQUAL
  12.5` on an `INTEGER` field returns every entity holding a number at `n`.
  **Audit any conditional-delete automation using a negative operator** — it now
  removes strictly more rows.
- **A `function` condition is rejected in a search body**, `400
  INVALID_CONDITION`, at any depth. It is a criteria-only clause; search never
  had a dispatcher for it.
- **A search whose model schema cannot be loaded is `500`**, not a `200` with a
  silently wrong result set.
- **A condition naming a data path on a model that declares no fields is
  `400 INVALID_FIELD_PATH`.**
- **Grouped stats validates its paths against the model** and no longer returns
  plausible-looking empty buckets for undeclared fields.
- **`501 NOT_IMPLEMENTED_BY_BACKEND` and `SCAN_BUDGET_EXHAUSTED` are retired.**
- **`waitForConsistencyAfter` is retired** from the seven entity write
  operations; still-sent values are ignored rather than rejected.

**Model and validation**

- **A value whose kind the field does not declare is rejected** — an array or an
  object into a `STRING` field now answers `400 VALIDATION_FAILED`.
- **Model field names must be valid path segments.** Ingestion that previously
  succeeded with an unspellable key now fails. No migration is provided: rename
  the key and re-establish the model.
- **`POLYMORPHIC_SLOT` is retired.** Giving a path a kind it does not declare is
  a `STRUCTURAL` change, so raising the level resolves it.
- **A payload failing against the model answers `400 VALIDATION_FAILED`, not
  `400 BAD_REQUEST`.** The status is unchanged; only a client branching on the
  code is affected.
- **`SIMPLE_VIEW` and `JSON_SCHEMA` emit different keys** for arrays of arrays,
  multi-kind fields and never-observed array elements. `JSON_SCHEMA` unions are
  `anyOf`, not `oneOf`.
- **A sample-data import body that is neither a document nor a collection of
  documents is `400 VALIDATION_FAILED`.**
- **Numeric- and temporal-leaf model folding is order-dependent under concurrent
  extension**, and that is accepted. Every reachable fold is monotone and admits
  every value written; what varies is only how widely a future value is admitted
  without a schema-change permission. Structural extension still converges
  byte-identically.
- **A workflow processor's returned data is governed by the model.** A processor
  writing a field outside its model now needs that model's `changeLevel`.

**Workflow**

- **Workflow schema moves to 1.4.** 1.1–1.4 are all accepted. Update any CI pin
  reading `"current"` from `/help/workflows/schema-version/versions`.
- **A criterion's `jsonPath`, operator names and pattern operands are validated
  at import**, `400 VALIDATION_FAILED`. Stored workflows keep evaluating and fail
  on their next re-import.
- **A criterion naming an undeclared field aborts and rolls back the save.**
- **Workflow selection is re-evaluated on every door.** An entity may re-bind to
  a different definition; if its current state is not declared there, the
  transition is `400 WORKFLOW_FAILED` and a loopback settles as a no-op.

**Operational**

- **`CYODA_TX_TTL`, `CYODA_TX_REAP_INTERVAL` and `CYODA_TX_OUTCOME_TTL` are
  removed.** They configured a reaper that never ran.
- **`CYODA_SQLITE_SEARCH_SCAN_LIMIT` is removed.** Remove it from your
  configuration; it is no longer read.
- **PostgreSQL connections carry `statement_timeout` and
  `idle_in_transaction_session_timeout`, both `5m` by default.** A processor
  whose `responseTimeoutMs` exceeds the idle ceiling has its transaction
  aborted; the 30s default sits well under it.
- **Pool acquisition fails with `503 STORAGE_UNAVAILABLE` after
  `CYODA_POSTGRES_ACQUIRE_TIMEOUT`** rather than queueing indefinitely.
- **`CYODA_SEARCH_ASYNC_MAX_PER_TENANT` lowers the accepted-in-flight ceiling**
  for single-tenant deployments from 264 to 8. Raise it or set `0`.
- **The SQLite reader pool raises the memory ceiling** to
  `(readers + 1) × CYODA_SQLITE_CACHE_SIZE`.
- **Upgrading a populated PostgreSQL deployment briefly blocks writers to
  `entities`.** Migration `000008` adds an index with a plain `CREATE INDEX`
  rather than `CREATE INDEX CONCURRENTLY`, which provably deadlocks this
  project's concurrent multi-node boot path. **Size a maintenance window to the
  `entities` table's row count before upgrading.** See `docs/plugins/POSTGRES.md`.
- **A `pointInTime` read inside a joined transaction is committed-only on
  PostgreSQL.** A compute-node callback that read its own uncommitted write this
  way now gets `404 ENTITY_NOT_FOUND`. Omit `pointInTime` — a current-state read
  inside a transaction is read-your-own-writes correct.
- **A recovered panic withdraws the node from service** (`/health` and `/readyz`
  report `503`) without restarting it.
- **The gRPC `EntityDeleteAllRequest.pageSize` field is removed** from the
  generated Go type. A client still sending it is tolerated.

**SPI (plugin authors)**

Out-of-tree plugins must be rebuilt against `cyoda-go-spi v0.8.4`:

- **`EntityStore` requires `Search` and `Iterate`;** `GetAll` and `GetAllAsAt`
  are removed, and the optional `Searcher` and `Iterable` interfaces are folded
  in. There is no deprecation window.
- **`CompareAndSave` must reject an empty `expectedTxID`** with a plain error,
  not `ErrConflict`.
- **`AsyncSearchStore` gains `Release`; `SearchJob` gains `StaleClaims`.** A
  `SelfExecutingSearchStore` may no-op `Release` and leave `StaleClaims` at 0.
- **`Filter.Prepare` / `PreparedFilter.Match` replace `MatchFilter`,
  `EvalLeafString`, `evalLeafFast` and `Expansion.Void`,** with no deprecation
  shim. A plugin doing its own per-row leaf evaluation must call `Prepare` once
  **above** its row-scan loop and `Match` inside it — substituting the pair in
  place inside the loop reintroduces the per-row cost the change removes.
- **`ConditionToFilter` and the model-schema read core now live in the SPI.**
  cyoda-go deletes its copies; a backend that self-executes searches can now
  reach the shared kernel instead of shipping a second evaluator.
- **`FieldDescriptor.MaxWidth`, `ArrayBranch.MaxWidth` and
  `ModelNode.ObserveArrayWidth` are removed.**
- **`ModelNode` holds a set of branches**, and its API changed accordingly. A
  plugin that decodes a schema itself needs the new pin.
- **`spitest` conformance gains cases** for the `TrackingRead` read-set contract
  on both filter-taking read entry points, `CompareAndSave/EmptyExpectedIDRejected`,
  and the `Release`/`StaleClaims` claim surface. A plugin that records per
  scanned row rather than per yielded row fails on its next dependency update.

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

## 🛡️ Security

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

## 🚀 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.4 release tag](https://github.com/Cyoda/cyoda-go/releases/tag/v0.8.4).

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