﻿# Cyoda-Go v0.8.2

Contract-true OpenAPI, richer entity queries, and transaction-joined compute callbacks.

_Released 8 July 2026 · 14 issues delivered_

This release sharpens the API contract and deepens the entity data layer.
The OpenAPI spec was reconciled end-to-end so that what the document
promises is exactly what the server does. Alongside that, entity reads and
writes gain sorting, partial updates, composite uniqueness, and a unified
point-in-time rule — while compute-node callbacks can now join the workflow
transaction that dispatched them.

## ✨ Highlights

- **Search result sorting:** Order search results by any scalar data field
  or meta field, ascending or descending, with canonical cross-backend
  ordering.
- **Partial entity updates (PATCH):** Update only the fields that changed
  with RFC 7386 merge-patch semantics — no more accidentally destroying
  fields you did not send.
- **Composite unique keys:** Declare multi-field uniqueness constraints on
  an entity model, enforced on create and update.
- **Faster PostgreSQL search:** Predicates now push down into SQL instead of
  loading and filtering every entity of a model in memory.
- **Transaction-joined compute callbacks:** Processor and criteria callbacks
  from a compute node now join the originating workflow transaction.
- **Renderer annotations on processors &amp; criteria:** Attach display
  metadata to every workflow element for visualisers and condition builders.
- **Contract-true OpenAPI:** The spec was reconciled with the server —
  error codes, response envelopes, and body shapes now match reality.

## 🔍 Details

### 🔎 Search: sorting and pushdown

Both search endpoints
(`POST /api/entity/{entityName}/{modelVersion}/search` and the async variant)
now accept `sort` query parameters (HTTP) or a structured `orderBy` array
(gRPC). The HTTP grammar is `[@]path[:asc|desc]` — a bare dotted path for a
scalar data field, an `@`-prefixed name for a meta field (`state`,
`creationDate`, `lastUpdateTime`, `transitionForLatestSave`, `transactionId`,
`id`).

Ordering is **canonical across every backend**: Text by byte order, Numeric
by IEEE-754 double, Bool as `false < true`, and Temporal chronologically.
Absent or null values sort last, and `entity_id` is always the final
tiebreaker. Unsortable, array, or unknown paths are rejected with
`400 INVALID_FIELD_PATH`. The sort-key count is capped by
`CYODA_SEARCH_MAX_SORT_KEYS` (default `16`).

Separately, **PostgreSQL search now pushes supported predicates into SQL** —
JSONB extraction plus numeric, range, and string comparisons run in the
database, and `LIMIT`/`OFFSET` are pushed down when no residual filtering
remains. Non-pushable operators (regex, case-insensitive) are post-filtered
as rows stream. This eliminates full-model scans and per-document decode; it
is a constant-factor win, not a JSON-path-index feature (indexing queried
paths remains a separate operational step). SQLite already did this; the
memory backend filters in memory by design.

### ✏️ Partial entity updates (PATCH)

A first-class partial-update capability closes a common data-loss footgun:
the existing `PUT` update has **wholesale-replace** semantics, so any field
omitted from the request was silently destroyed.

```http
PATCH /api/entity/{format}/{entityId}
PATCH /api/entity/{format}/{entityId}/{transition}
```

The request body is a sparse JSON object applied to the **stored** entity
with RFC 7386 (`application/merge-patch+json`) semantics: a non-null key
overwrites, an explicit `null` deletes the key, and an omitted key is left
untouched. JSON-only (`XML` ⇒ `415`); RFC 6902 JSON Patch is recognised but
returns `501` for now.

Unlike `PUT`, **`If-Match` is required** — the merge is applied relative to
the base you read, so patching a stale base risks a lost update. The token is
the `meta.transactionId` from your last `GET`. A missing `If-Match` returns
`428 PRECONDITION_REQUIRED`; a stale one returns `412`. The merged result is
validated strictly against the model schema — a PATCH never extends the
model, even in an extend-permitting mode.

### 🔑 Composite unique keys

Entity models can now declare one or more composite unique keys via
`PUT /model/{entityName}/{modelVersion}/unique-keys` (UNLOCKED models only).
Each key is an ordered set of scalar field paths; uniqueness is scoped to
`(tenant, model, version)` over live entities. The null rule is
all-or-nothing: all fields null or absent ⇒ exempt; partial ⇒
`422 INVALID_UNIQUE_KEY`; all present ⇒ enforced on create and update. String
comparison is byte-exact, and soft-delete frees the value-set. Supported by
the memory, sqlite, and postgres backends; the commercial backend returns
`422 COMPOSITE_KEY_UNSUPPORTED` until its own support lands.

New error codes: `UNIQUE_VIOLATION` (409), `INVALID_UNIQUE_KEY` (422),
`COMPOSITE_KEY_UNSUPPORTED` (422), `INVALID_UNIQUE_KEY_DEFINITION` (422).

### 🗑️ Conditional delete and point-in-time list reads

`DELETE /api/entity/{entityName}/{modelVersion}` now honours an
`AbstractConditionDto` request body and deletes only matching entities (an
empty body still means all). This **closes a data-loss defect** where the
condition was ignored and the entire model was wiped. `verbose=true` returns
the deleted ids, and matched-vs-removed counts are reported separately. New
error code `INVALID_CONDITION` (400).

The model-scoped list read (`getAllEntities`) now honours `pointInTime`,
returning entities as-at the supplied instant and stamping `meta.pointInTime`.

### ⏱️ One point-in-time rule everywhere

Point-in-time ("as at T") reads now apply a **single canonical rule** across
every storage engine and read path: inclusive of the requested instant
(`<=`), compared at native precision, with no millisecond round-up.
Previously the memory engine and the SQL `GetAsAt`/`GetAllAsAt` paths rounded
up to the next millisecond while sqlite used a strict `<` bound — so
different backends, and even different read paths within one backend, could
disagree at sub-millisecond boundaries. That inconsistency is gone.

### 🔗 Transaction-joined compute-node callbacks

Processor and criteria-evaluation callbacks from a compute node now **join
the originating workflow transaction (`T`)** rather than running standalone.
Before each dispatch the engine mints a signed HMAC tx-token and attaches it
to the outbound CloudEvent as the `cyodatxtoken` extension attribute. Compute
nodes echo it on callbacks (`X-Tx-Token` HTTP header / `tx-token` gRPC
metadata); the receiving node verifies the HMAC and routes the callback to
the transaction owner — a local join when the owner is self, or an HTTP
reverse-proxy / gRPC forward otherwise.

Callbacks see the cascade's uncommitted writes, and their acks stay
provisional until `T` commits. `ASYNC_NEW_TX` callbacks join `T` via a
savepoint, so a processor failure discards its writes without aborting the
cascade. An absent token falls back to standalone execution — the normal
behaviour for `COMMIT_BEFORE_DISPATCH` with `startNewTxOnDispatch=false`.

New env vars: `CYODA_TX_TOKEN_TTL` (token validity, default `90s`),
`CYODA_GRPC_NODE_ADDR` (gRPC address advertised in tokens for B→A
forwarding), and `CYODA_COMPUTE_HTTP_BASE` (base URL for compute-test-client
HTTP callbacks).

### 🧩 Renderer annotations on processors and criteria

The engine-ignored `annotations` bag — previously available on workflows,
states, and transitions — now extends to the two elements that lacked it:

- **Processors** carry an embedded `annotations` object.
- **Criteria** carry a sibling `criterionAnnotations` object on the workflow
  and on each transition (the criterion tree itself round-trips verbatim and
  is never parsed to attach metadata).

Two well-known optional keys — `displayName` and `description` — are now
documented uniformly across all five element types for renderer and
condition-builder use. As before, annotations are object-only, capped at
64 KB per field, stored and re-emitted compacted, and **never interpreted by
the engine**; processor annotations are stripped before dispatch and never
reach compute members. This is an additive schema change: the workflow schema
moves to **1.2**, and every existing 1.1 payload remains valid.

### 📐 OpenAPI contract reconciliation

A substantial slice of this release makes the OpenAPI document tell the truth
about the running server, so generated clients and conformance tools can
trust it:

- **Typed-but-open entity `meta`** mirroring the canonical `EntityMetadata`;
  the obsolete `previousTransition` field is removed.
- **Error-code conformance** — the E2E validator now enforces documented
  `errorCode` strings for entity endpoints, not just response shapes.
- **`ProblemDetail` envelopes** (`application/problem+json`) declared for the
  OIDC provider ops, audit search, async search, and the state-machine
  finished-event endpoint; the duplicate `ProblemDetailDto` schema was
  consolidated. `getTechnicalUserToken` keeps its RFC-6749 flat OAuth shape.
- **Config-conditional `501`** now documented for the 21 IAM-gated
  operations when `CYODA_IAM_MODE ≠ jwt`, plus `404 FEATURE_DISABLED` on the
  trusted-key ops when that feature is off.
- **Uniform `404 MODEL_NOT_FOUND`** across every model-scoped read when the
  model is not registered for the tenant (these paths previously returned
  empty results silently).
- **Dead-surface disposition** — SQL-schema drift fixes, CQL cleanup, and a
  marker-backing invariant; fictional contract surface (a phantom `403`,
  `408`, `timeoutMillis`, a no-op `pointInTime` param, a bogus UUID
  constraint) removed.
- **Edge-message API** — the request envelope's optional metadata map is now
  `metaData` (camelCase), symmetric with the response, and a new
  `cyoda help messages` topic documents the full edge-message surface.

This release tightens several contracts. Review these before upgrading:

- **Edge-message `meta-data` → `metaData`.** The
  `POST /api/message/new/{subject}` request envelope now carries its optional
  metadata under `metaData`; the old kebab-case `meta-data` key is ignored.
  Shipped in a patch because edge messages have no known consumers.
- **`changeType` spelling.** Entity change records now use canonical
  `CREATE`/`UPDATE`/`DELETE` over HTTP (previously `CREATED`/`UPDATED`/
  `DELETED`); gRPC and OpenAPI already agreed.
- **`DELETE /model/{entityName}/{modelVersion}` enforces UNLOCKED.** Deleting
  a `LOCKED` model now returns `409 MODEL_ALREADY_LOCKED` instead of ignoring
  the lock. Unlock the model first.
- **`searchEntities` limit is enforced, not clamped.** `limit > 10000` now
  returns `400 BAD_REQUEST` across sync search (HTTP), gRPC direct search,
  and async submission; the spec previously described a silent clamp.
- **`searchEntities` responds `application/x-ndjson` only.** The
  previously-listed `application/json` variant is removed from the contract.
- **`listOidcProviders.activeOnly` is now a strict boolean.** Truthy values
  (`1`, `true`, `t`) filter correctly; unparseable values such as
  `?activeOnly=yes` return `400` instead of silently meaning false.
- **`registerOidcProvider` duplicate returns `409`** (`OIDC_PROVIDER_DUPLICATE`),
  not `400`. The `400` path remains for validation failures.
- **Removed fictional parameters.** The no-op `pointInTime` on
  `getAsyncSearchResults`, the `timeoutMillis` param and `408` on
  `searchEntities`, and the fictional time-based-UUID `400` on
  `getStateMachineFinishedEvent` are all gone from the contract.

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

## 🛡️ Security

- **Go toolchain 1.26.4 → 1.26.5** to clear govulncheck advisory
  GO-2026-5856.
- **`goldmark` v1.7.13 → v1.7.17** (indirect) to clear GO-2026-5320 (XSS in
  goldmark HTML rendering, reached via `glamour` in the `cyoda help`
  renderer). The renderer only formats first-party help content embedded in
  the binary, so the advisory was never reachable with attacker-controlled
  input; the bump keeps the scan clean.

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

## 💬 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.2 is a patch, not a minor

This release adds several genuinely new capabilities, yet it ships as a
patch. That is deliberate. Pre-1.0, we follow the widely-adopted convention
(Cargo, npm `^0.x`) where the **leftmost non-zero component is the de-facto
major** and the minor component is reserved as a **breaking-change signal**.
Features ship in patches; a minor bump means "read the migration notes".

We also **decoupled the `cyoda-go-spi` version from the binary version**. The
two move independently now — the SPI is pinned per release, but its number no
longer has to march in lock-step with the engine. See
[docs/workflow-schema-versioning.md](https://github.com/Cyoda/cyoda-go/blob/main/docs/workflow-schema-versioning.md)
and the [README versioning section](https://github.com/Cyoda/cyoda-go#versioning)
for the full convention.

This release does still carry breaking contract changes — see the breaking
changes noted above — but they are contract-tightening corrections, not a new
feature epoch.