Read models
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, collections and history, and decision reads have their own references.
Registration reference
Section titled “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. 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
Section titled “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
Section titled “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 useValuewhenExistsis true. LastHandledis 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
nullis absence; malformed, empty or non-object documents fail withchronicle.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
Section titled “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). Another client
Release cannot repair that loss and can itself corrupt already-released values.
Lifetime, interruption and overload
Section titled “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. DrainRecv()to receive queued values followed by the terminal error, or inspectErr()afterDone().- 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 withErrOverloaded. 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
Section titled “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
Section titled “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.
Leaving a window is not proof of document deletion.
Hydration sessions
Section titled “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); 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
Section titled “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; 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 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
Section titled “Run the example”The read-model example 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.
CHRONICLE_INTEGRATION_CONNECTION_STRING=chronicle://localhost:35000 \ GOWORK=off go run ./examples/readmodelsExpected output:
Example.InventoryItem registered; item-1 exists: falseFor watches, projection-backed callbacks and an initial materialized window, run the watch example:
CHRONICLE_INTEGRATION_CONNECTION_STRING=chronicle://localhost:35000 \ GOWORK=off go run ./examples/watchesIt 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 for the C# mapping and remaining boundaries.