---
title: Read models
editUrl: https://github.com/Cratis/Chronicle.Go/edit/main/Documentation/read-models/index.md
description: Register model schemas and read typed or raw state without confusing absence with a zero value.
lastUpdated: 2026-10-07T00:00:00.000Z
---


Use `chronicle.RegisterReadModel[T]` to declare a model before constructing the client. A model describes state produced by a projection or reducer; **registration alone does not create an instance**. This page covers catalog registration, one-shot reads, watches, materialized windows, projection sessions and release. [Projection authoring](/chronicle/go/projections/), [collections and history](/chronicle/go/read-models/collections-and-history/), and [decision reads](/chronicle/go/read-models/decision-reads/) have their own references.

## Registration reference

`RegisterReadModel[T](registry, options...)` returns `(readmodels.Model[T], error)`. `T` must be a named, non-pointer struct supported by the shared [serialization contract](/chronicle/go/events/event-types/). Unsupported shapes and conflicting protection tags fail before network I/O.

| Option | Default and contract |
| --- | --- |
| `WithIdentifier(id)` | Full Go import path plus `.` and the simple type name. Set an explicit C# fully qualified name to share a model across languages |
| `WithContainerName(name)` | Case-preserving English plural of the simple type name: `Person` → `People`, `OrderSummary` → `OrderSummaries`. Independent of the identifier |
| `WithDisplayName(name)` | Simple Go type name |
| `WithGeneration(generation)` | One; must be positive. This selects the current schema, not a migration chain |
| `WithSink(readmodels.Sink{...})` | Overrides the client default, including an explicit `MongoDB`. Supported types: `MongoDB`, `SQL`, `InMemory`, `NoSink`; configuration IDs use canonical UUID strings and default to all-zero |
| `WithObserver(kind, id)` | `Projection`, empty producer ID. Associates a producer; does not register it |
| `WithEventSequence(sequence)` | `event-log`; used for immediate reads and session cleanup |
| `WithIndexes(paths...)` | None. Serialized dot paths can traverse nested objects and collection items |
| `WithPII(paths...)` | None. Marks properties with kernel PII metadata, including composite leaves and coarse collections |
| `WithProtection(declarations...)` | None. Type/property PII or distinct subject/namespace/global confidentiality classifications and providers |
| `WithSubjectProperty(path)` | No explicit subject property. Selects a top-level scalar property for release, falling back to the Go `ID` field; `subject` tags use the same resolver |

Use `chronicle:"index"` on a model field for the model-bound equivalent of
`WithIndexes`. Tag paths and explicit paths accumulate into the same metadata;
duplicates involving a tag fail with `DeclarationError` (explicit-only duplicates
retain `ErrInvalidConfiguration`). Nested objects and
collection items are traversed using serialization-plan names. Traversal stops
recursive cycles per path, so two properties of the same nested type both retain
their indexes. Index tags neither subscribe to events nor create a projection.

Scalar options are last-wins. Collections are copied; duplicate index/PII paths, unknown paths, zero generations and blank required names fail. Neither PII nor confidentiality can protect an `events.SourceID` value, including through type or nested metadata. No public API requires a UUID dependency or a concept/date wrapper.

`WithRegistry` freezes declarations at `NewClient` time. `WithRegistryForStore` replaces the entire catalog for that store. Later registry changes do not alter existing clients. Duplicate Go model types or identifiers fail atomically.

The store registers definitions after event types and before returning a ready handle. Reconnect replays them through the existing registration barrier. Definitions are store-wide; instance requests and sessions carry the selected namespace. Registration uses C#'s `Client` owner and `Code` source. Inspect `store.ReadModels().Catalog()` through `Descriptors`, `Lookup`, `LookupType` or `LookupIdentifier` without I/O.

## Client default sink

Pass `chronicle.WithDefaultSinkType(readmodels.SQL)` to `NewClient` to select
SQL for models without `readmodels.WithSink`. Omitted means MongoDB; MongoDB,
SQL and InMemory are the accepted materialized providers. Scalars are last-wins;
unknown types, an empty type and `NoSink` fail with `ErrInvalidConfiguration`
before factory preparation or network I/O. The option applies to the default
registry and every per-store replacement, never an individual namespace.

Precedence is passive producer → `NoSink`; otherwise explicit model `WithSink`
→ client default → MongoDB. An explicit materialized sink conflicts with a
passive producer, even when it is MongoDB. Explicit `NoSink` requires passive
projection or reducer semantics; it is not a global materialized default.
Provider configuration UUIDs remain per-model and are never replaced by the
client default.

Sink selection leaves model generation, schema, codec/classification snapshots
and original typed handles unchanged. Two clients can reuse one registry with
different defaults. `Client.Catalogs(store)` reports the same final definitions
as the store handles, without I/O; reconnect reuses their frozen registration.
See the compiling `ExampleWithDefaultSinkType` for offline selection and an
explicit MongoDB override.

These options select kernel-owned providers, not client database drivers or
connection configuration. A successful registration does not prove that the
server has a usable SQL or InMemory backend. The pinned kernel witness covers
MongoDB materialization, including a MongoDB model overriding a SQL client
default; live SQL and InMemory support are not qualified by that witness.

## One-shot reads

Create a typed reader with `readmodels.For(store.ReadModels(), model)`. Its `Get(ctx, key)` returns `(readmodels.Instance[T], error)`:

- Check the error first, then `Exists`. Only use `Value` when `Exists` is true.
- `LastHandled` is a separate `*events.SequenceNumber`. Nil means unavailable; position zero is valid. A removed model can be absent and still have a last-handled position.
- A present empty object is a valid zero-valued model. JSON `null` is absence; malformed, empty or non-object documents fail with `chronicle.ErrProtocol`.
- Present models normalize absent/null slice fields to empty slices, including nested collections. Use a pointer-to-slice to retain nullable presence. Maps retain their own null semantics.

For raw reads, call `store.ReadModels().Get(ctx, model.Identifier(), key)`. It returns `Instance[json.RawMessage]`, preserves stored metadata and never manufactures an absent document. Keys are preserved verbatim; blank keys are invalid and the kernel's `*` replay wildcard is unsupported by this one-instance API.

Reads require a declaration in the service catalog. A typed reader also requires the exact declaration retained from registration, not a separately constructed equivalent. Unknown/foreign declarations return `ErrNotRegistered`. Materialized reads are eventually consistent; neither `LastHandled` nor a session is a concurrency-protection token.

## Watch changes

`reader.Watch(ctx, options...)` returns a `*readmodels.Subscription[readmodels.Change[T]]`
only after the kernel sends `Subscribed`. **Subscription readiness is not a current
model snapshot.** Establish the watch before producing events you need to observe.
A projection watch does not require a reducer.

Use `Recv()` for one change at a time, or range over `subscription.Values()`.
`Change.Type` is `Added`, `Modified` or `Removed`; `Key` is always available.
Only use `Value` when `HasValue` is true. A removal may carry the old model or no
value. `Context` preserves the available position, occurrence and correlation;
the event type and undisclosed audit fields remain unknown. Requests use the
model's configured event sequence, rather than C#'s hard-coded event log.

The raw equivalent is `store.ReadModels().Watch(ctx, model.Identifier())`.
Both APIs validate the returned namespace and normalize model IDs/collections as
one-shot reads do. Classified models fail with `ErrUnsupported` before subscribing.
The pinned kernel can corrupt legitimate plaintext on the projection watch route,
including namespace-encrypted values after PII erasure
([Chronicle#4566](https://github.com/Cratis/Chronicle/issues/4566)). Another client
`Release` cannot repair that loss and can itself corrupt already-released values.

### Lifetime, interruption and overload

- The opening context controls the entire subscription, not just startup. Always
  call `Close()` after early consumption; it cancels and joins the receive worker.
  `Values()` also closes on early break. One consumer may receive at a time.
- `Done()` signals worker completion. Drain `Recv()` to receive queued values
  followed by the terminal error, or inspect `Err()` after `Done()`.
- An interrupted stream ends with `ErrInterrupted`, retaining its transport
  status or EOF. Caller cancellation retains its context error. There is **no
  silent resubscribe, durable cursor or gap-free resume**. Re-watch and re-fetch;
  a separate refetch is not an atomic handoff with the change feed.
- Defaults are 64 queued messages and 8 MiB of serialized data. Override both
  with `readmodels.WithWatchBuffer(messages, bytes)`. Exceeding either limit,
  including one oversized message, ends the subscription with `ErrOverloaded`.
  Decoded values and gRPC transport buffers are additional memory. No change is
  silently discarded while reporting success.
- Watches are independent of hydration sessions: no session selector exists on
  the watch RPC. Closing a session does not close a watch, and closing a watch
  does not dehydrate a session. Neither supplies a protected decision token.

### Local reducer notifications

For a registered reducer, `Watch` attaches to successful folds delivered through
this client's reducer observation runtime instead. Passive one-shot reads do not
publish into this feed. Readiness is a local attachment, not a server
`Subscribed` signal. Unclassified fold values are already plaintext and are
validated without a remote release; changes are `Modified` or `Removed`, with
unknown event metadata. Classified local watches fail with `ErrUnsupported`:
notifications do not carry persisted lineage or a delivery-time erasure fence.
Erasure does not revoke arbitrary historical copies in application memory.
This does not observe other clients,
prove a sink write, or provide durable recovery. Local notifications and decoded
deliveries each have the configured bounded queue. Generation replacement ends
existing watches; retired folds cannot publish into a new generation's watches.

## Materialized windows

`reader.Materialized().GetInstances(ctx, window)` reads sink-backed instances.
`ObserveInstances(ctx, window, options...)` returns a subscription of **complete
replacement windows**, not changesets. The first `Recv()` yields the initial
snapshot. This RPC has no `Subscribed` marker: opening waits for the first
successfully validated and decoded window instead. The kernel releases stored
values using persisted lineage; the SDK does not decrypt the reply again. The
kernel may coalesce intermediate snapshots under load. Validation and decoding
preserve receive order.

Protected windows admit only MongoDB root string PII and namespace-encrypted
properties, excluding classified sink identity aliases (`_id` and case-insensitive
`id`). Type and provider declarations must compile to that same scalar schema;
nested, nullable, collection, other encryption-scope and other sink profiles fail
with `ErrUnsupported` before RPC. Missing classified roots or invalid final values
fail without returning a partial window. A string's shape alone is not proof of
decryption: this boundary relies on the admitted server route's release contract.

Pass nil for the C# defaults, skip 0 / take 50, or pass
`&readmodels.Window{Skip: 5, Take: 10}`. Negative skip becomes zero. Take zero or
negative means empty except `readmodels.UnlimitedInstances` (-1). Aligned ranges
use a kernel page directly; non-aligned ranges fetch a covering page from the
start and slice locally, capped at `math.MaxInt32`. Large covering/unlimited
requests can be expensive. Passive models have no sink window and fail explicitly.

The raw APIs are `store.ReadModels().Materialized().GetInstances` and
`ObserveInstances`, with the model identifier before the window argument.
Subscriptions have the same cancellation, buffering and terminal-error contract
as `Watch`. To react to window membership changes, use a
[materialized read-model reactor](/chronicle/go/read-model-reactors/#materialized-reactors).
Leaving a window is not proof of document deletion.

## Hydration sessions

`reader.NewSession(key)` returns a lazy `*readmodels.Session[T]`. It requires a declared projection identifier. `session.Get(ctx)` hydrates and subsequently reads using one opaque session ID. A session is permanently bound to its model, key, store, namespace and event sequence. Reducer sessions fail with `ErrUnsupported` before RPC. Classified projection models are supported from kernel 19.32.2, where the session releases each value with the subject it was written under ([Chronicle#4561](https://github.com/Cratis/Chronicle/issues/4561)); against an older or unverified kernel, `session.Get` refuses them with `ErrUnsupported` before RPC.

Call `session.Close(cleanupCtx)` **before closing the client**, including after a failed or canceled read. The kernel may have hydrated state even when the response was lost. Cleanup uses the actual sequence, not an unconditional event-log request. Close is idempotent after success; failed cleanup returns its error and can be retried with a fresh, bounded context. Once cleanup starts, further reads return `ErrClosed`.

Sessions own no background goroutine. Reads and cleanup serialize with cancellation-aware admission. Cancellation of a waiting cleanup does not cancel another caller's read. Reconnect does not promise preservation or automatic reconstruction of hydrated state; protected decision sessions are not implemented.

## Release externally loaded documents

`reader.Release(ctx, value)`, `reader.ReleaseMany(ctx, values)` and `store.ReadModels().Release(ctx, model.Identifier(), rawDocument)` accept **unreleased documents fetched directly from a sink**. They are not idempotent: legitimate plaintext can resemble ciphertext. Never call them again on server-released read results.

Materialized keyed/collection reads, projection replay, history, immediate/session reads and decision reads all return server-released values; the SDK validates their final representation without another release RPC. Classified projection Watch still refuses before streaming. See the [release-route limits](/chronicle/go/read-models/collections-and-history/#protection-failure-and-lifetime); validation alone cannot prove that a string was decrypted.

Release uses the model schema and namespace. Raw documents preserve `__subject` and `__subjects` lineage; a stored `__subject` takes precedence, followed by the configured subject property and then the Go `ID` field's serialized name. Without protection metadata, typed values pass through and raw documents are copied without an RPC.

Unlike C#'s best-effort release, Go returns `ErrRelease` and no model when a present subject-protected value has no owner, the release RPC fails, the kernel reports a release error, or the response is malformed. Inspect `*readmodels.ReleaseError` with `errors.As`; context and unsupported-operation identities survive wrapping. Error text omits payloads and subjects.

Namespace/global confidentiality can release without a subject. See [compliance and confidentiality](/chronicle/go/compliance/) for field/type classifications, erasure, reauthorization and pinned-kernel limitations. Unsupported codecs and conflicting declarations fail rather than silently publishing plaintext. Kernel release remains authoritative for cryptographic correctness and erased-value behavior.

## Run the example

The [read-model example](https://github.com/Cratis/Chronicle.Go/blob/main/examples/readmodels/main.go) creates a unique store, registers an inventory model and verifies that an uncreated item is absent. It requires Go 1.26 or newer and a running `cratis/chronicle:19.32.3-development` kernel. It uses development credentials and disables certificate validation; do not use that configuration in production.

```sh
CHRONICLE_INTEGRATION_CONNECTION_STRING=chronicle://localhost:35000 \
  GOWORK=off go run ./examples/readmodels
```

Expected output:

```text
Example.InventoryItem registered; item-1 exists: false
```

For watches, projection-backed callbacks and an initial materialized window,
run the [watch example](https://github.com/Cratis/Chronicle.Go/blob/main/examples/watches/main.go):

```sh
CHRONICLE_INTEGRATION_CONNECTION_STRING=chronicle://localhost:35000 \
  GOWORK=off go run ./examples/watches
```

It prints `watch: product-1 = Coffee`, `reactor: Coffee`, then
`materialized window: 1 product`. It also uses development-only credentials/TLS
and retains a uniquely named store.

Each read-model example run retains its uniquely named store in your development kernel. It does not delete data or install a projection. See [parity and migration notes](/chronicle/go/parity/#read-model-migration) for the C# mapping and remaining boundaries.
