Skip to content

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.

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.

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

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.

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.

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.

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

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.

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.

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.

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.

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.

Terminal window
CHRONICLE_INTEGRATION_CONNECTION_STRING=chronicle://localhost:35000 \
GOWORK=off go run ./examples/readmodels

Expected output:

Example.InventoryItem registered; item-1 exists: false

For watches, projection-backed callbacks and an initial materialized window, run the watch example:

Terminal window
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 for the C# mapping and remaining boundaries.